Compare commits
4 Commits
d07a02c9dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
534d66c774
|
|||
|
7f1e82acc2
|
|||
|
5b4955f07e
|
|||
|
108c042921
|
4
.envrc
Normal file
4
.envrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export SUPABASE_URL=$(npx supabase status 2>/dev/null | grep -oP '(?<=Project URL │ )\S+')
|
||||||
|
export SUPABASE_SERVICE_ROLE_KEY=$(npx supabase status 2>/dev/null | grep -oP '(?<=Secret │ )\S+')
|
||||||
|
export SUPABASE_KEY=$(npx supabase status 2>/dev/null | grep -oP '(?<=Publishable │ )\S+')
|
||||||
|
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["denoland.vscode-deno"]
|
||||||
|
}
|
||||||
24
.vscode/settings.json
vendored
Normal file
24
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"deno.enablePaths": [
|
||||||
|
"supabase/functions"
|
||||||
|
],
|
||||||
|
"deno.lint": true,
|
||||||
|
"deno.unstable": [
|
||||||
|
"bare-node-builtins",
|
||||||
|
"byonm",
|
||||||
|
"sloppy-imports",
|
||||||
|
"unsafe-proto",
|
||||||
|
"webgpu",
|
||||||
|
"broadcast-channel",
|
||||||
|
"worker-options",
|
||||||
|
"cron",
|
||||||
|
"kv",
|
||||||
|
"ffi",
|
||||||
|
"fs",
|
||||||
|
"http",
|
||||||
|
"net"
|
||||||
|
],
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
CLAUDE.md
78
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()
|
- `app/middleware/auth.ts` — global auth guard via useSupabaseUser()
|
||||||
- Auth pages use `definePageMeta({ layout: false })` (effectively a no-op with IonRouterOutlet, but documents intent)
|
- 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
|
### Auth
|
||||||
- Supabase Auth — magic link + OTP only (no password auth)
|
- Supabase Auth — magic link + OTP only (no password auth)
|
||||||
- `useSupabaseUser()` composable (from @nuxtjs/supabase)
|
- `useSupabaseUser()` composable (from @nuxtjs/supabase)
|
||||||
@@ -41,10 +61,68 @@ You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat app (oysq
|
|||||||
- Types in `types/supabase.ts` — regenerate with: `npx supabase gen types typescript --project-id YOUR_ID > types/supabase.ts`
|
- Types in `types/supabase.ts` — regenerate with: `npx supabase gen types typescript --project-id YOUR_ID > types/supabase.ts`
|
||||||
- `useSupabaseClient<Database>()` typed against `types/supabase.ts`
|
- `useSupabaseClient<Database>()` typed against `types/supabase.ts`
|
||||||
|
|
||||||
|
### Edge Functions
|
||||||
|
- Located in `supabase/functions/<name>/` — each function has its own `deno.json`
|
||||||
|
- Auth pattern: extract Bearer token → `adminClient.auth.getUser(token)` (pass JWT directly to service-role client). Do NOT create a separate userClient with the anon key.
|
||||||
|
- Use `SUPABASE_SERVICE_ROLE_KEY` (adminClient) for all DB operations inside functions; the caller's identity comes from JWT claims (`claims.sub` = user ID).
|
||||||
|
- **SELinux (Fedora/RHEL local dev)**: Before running `supabase functions serve`, label the project directory for container access:
|
||||||
|
```
|
||||||
|
sudo chcon -Rt container_file_t $(pwd)
|
||||||
|
```
|
||||||
|
This must be applied after any `git clone` or directory move. Failure symptom: function bootstrap error with no useful stderr output.
|
||||||
|
|
||||||
### Icons
|
### Icons
|
||||||
- Ionicons only (`ionicons/icons`) — no PrimeIcons
|
- Ionicons only (`ionicons/icons`) — no PrimeIcons
|
||||||
- Always import individual icon names from `ionicons/icons` (tree-shakeable)
|
- 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
|
## Rules
|
||||||
|
|
||||||
1. Do not mix unrelated project contexts in one session.
|
1. Do not mix unrelated project contexts in one session.
|
||||||
|
|||||||
96
app/app.vue
96
app/app.vue
@@ -1,6 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<IonApp>
|
<IonApp>
|
||||||
<IonMenu content-id="main-content" menu-id="main-menu">
|
<!-- 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>
|
<IonHeader>
|
||||||
<IonToolbar color="primary">
|
<IonToolbar color="primary">
|
||||||
<IonTitle>OYS Borrow a Boat</IonTitle>
|
<IonTitle>OYS Borrow a Boat</IonTitle>
|
||||||
@@ -35,9 +42,17 @@
|
|||||||
<IonItemDivider>
|
<IonItemDivider>
|
||||||
<IonLabel>Management</IonLabel>
|
<IonLabel>Management</IonLabel>
|
||||||
</IonItemDivider>
|
</IonItemDivider>
|
||||||
<IonItem button router-link="/schedule/manage" router-direction="root" @click="closeMenu">
|
<IonItem button router-link="/admin/intervals" router-direction="root" @click="closeMenu">
|
||||||
<IonIcon slot="start" :icon="calendarNumberOutline" />
|
<IonIcon slot="start" :icon="calendarNumberOutline" />
|
||||||
<IonLabel>Manage Schedule</IonLabel>
|
<IonLabel>Manage Slots</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/admin/templates" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="layersOutline" />
|
||||||
|
<IonLabel>Templates</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem button router-link="/admin/reservations" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="bookmarkOutline" />
|
||||||
|
<IonLabel>Manage Bookings</IonLabel>
|
||||||
</IonItem>
|
</IonItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -51,6 +66,10 @@
|
|||||||
<IonIcon slot="start" :icon="constructOutline" />
|
<IonIcon slot="start" :icon="constructOutline" />
|
||||||
<IonLabel>Manage Boats</IonLabel>
|
<IonLabel>Manage Boats</IonLabel>
|
||||||
</IonItem>
|
</IonItem>
|
||||||
|
<IonItem button router-link="/admin/config" router-direction="root" @click="closeMenu">
|
||||||
|
<IonIcon slot="start" :icon="settingsOutline" />
|
||||||
|
<IonLabel>Booking Rules</IonLabel>
|
||||||
|
</IonItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Sign out -->
|
<!-- Sign out -->
|
||||||
@@ -65,22 +84,28 @@
|
|||||||
</IonMenu>
|
</IonMenu>
|
||||||
|
|
||||||
<IonRouterOutlet id="main-content" />
|
<IonRouterOutlet id="main-content" />
|
||||||
|
</IonSplitPane>
|
||||||
</IonApp>
|
</IonApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
IonApp, IonMenu, IonHeader, IonToolbar, IonTitle,
|
IonApp, IonSplitPane, IonMenu, IonHeader, IonToolbar, IonTitle,
|
||||||
IonContent, IonList, IonItem, IonItemDivider, IonIcon, IonLabel, IonNote,
|
IonContent, IonList, IonItem, IonItemDivider, IonIcon, IonLabel, IonNote,
|
||||||
IonRouterOutlet, menuController,
|
IonRouterOutlet, menuController,
|
||||||
} from '@ionic/vue'
|
} from '@ionic/vue'
|
||||||
import {
|
import {
|
||||||
homeOutline, calendarOutline, boatOutline, personOutline,
|
homeOutline, calendarOutline, boatOutline, personOutline,
|
||||||
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
||||||
|
layersOutline, settingsOutline, bookmarkOutline, cloudOfflineOutline,
|
||||||
} from 'ionicons/icons'
|
} from 'ionicons/icons'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||||
|
import { useAppCache } from '~/composables/useAppCache'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { isOnline } = useOfflineStatus()
|
||||||
|
const cache = useAppCache()
|
||||||
|
|
||||||
async function closeMenu() {
|
async function closeMenu() {
|
||||||
await menuController.close('main-menu')
|
await menuController.close('main-menu')
|
||||||
@@ -101,4 +126,67 @@ watch(() => authStore.user, (u) => {
|
|||||||
if (u) authStore.fetchMember()
|
if (u) authStore.fetchMember()
|
||||||
else authStore.member = null
|
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>
|
</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 }
|
||||||
|
}
|
||||||
23
app/composables/useBoatImage.ts
Normal file
23
app/composables/useBoatImage.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const BUCKET = 'boat-images'
|
||||||
|
|
||||||
|
export function useBoatImage() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
function url(path: string | null | undefined, width?: number, height?: number): string {
|
||||||
|
if (!path) return ''
|
||||||
|
const opts = width && height
|
||||||
|
? { transform: { width, height, resize: 'cover' as const } }
|
||||||
|
: undefined
|
||||||
|
const { data } = supabase.storage.from(BUCKET).getPublicUrl(path, opts)
|
||||||
|
return data.publicUrl as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnail: (path: string | null | undefined) => url(path, 150, 150),
|
||||||
|
small: (path: string | null | undefined) => url(path, 400, 300),
|
||||||
|
medium: (path: string | null | undefined) => url(path, 800, 600),
|
||||||
|
large: (path: string | null | undefined) => url(path, 1200, 900),
|
||||||
|
original: (path: string | null | undefined) => url(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
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) }
|
||||||
|
}
|
||||||
501
app/pages/admin/boat.vue
Normal file
501
app/pages/admin/boat.vue
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Manage Boats</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="openCreate">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="boats.length === 0" class="empty-text">No boats yet. Add one to get started.</p>
|
||||||
|
|
||||||
|
<IonCard v-else v-for="boat in boats" :key="boat.id" class="boat-card">
|
||||||
|
<div class="boat-card-inner">
|
||||||
|
<div class="boat-thumb-wrap">
|
||||||
|
<img
|
||||||
|
v-if="boat.img_src"
|
||||||
|
:src="boatImage.thumbnail(boat.img_src)"
|
||||||
|
class="boat-thumb"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div v-else class="boat-thumb-placeholder">
|
||||||
|
<IonIcon :icon="boatOutline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="boat-info">
|
||||||
|
<div class="boat-name">{{ boat.display_name || boat.name }}</div>
|
||||||
|
<div class="boat-meta">
|
||||||
|
<span v-if="boat.class">{{ boat.class }}</span>
|
||||||
|
<span v-if="boat.year">{{ boat.year }}</span>
|
||||||
|
<span>{{ boat.max_passengers }} pax</span>
|
||||||
|
</div>
|
||||||
|
<div class="cert-chips" v-if="boat.required_certs.length">
|
||||||
|
<span v-for="c in boat.required_certs" :key="c" class="cert-chip">{{ c }}</span>
|
||||||
|
</div>
|
||||||
|
<IonBadge :color="boat.booking_available ? 'success' : 'medium'" class="avail-badge">
|
||||||
|
{{ boat.booking_available ? 'Available' : 'Out of service' }}
|
||||||
|
</IonBadge>
|
||||||
|
</div>
|
||||||
|
<div class="boat-actions">
|
||||||
|
<IonButton fill="clear" @click="openEdit(boat)">
|
||||||
|
<IonIcon slot="icon-only" :icon="pencilOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton fill="clear" color="danger" @click="confirmDelete(boat)">
|
||||||
|
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<!-- Create / Edit modal -->
|
||||||
|
<IonModal :is-open="showModal" @did-dismiss="closeModal">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{{ editing ? 'Edit Boat' : 'New Boat' }}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="closeModal">Cancel</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
|
||||||
|
<!-- Image upload section -->
|
||||||
|
<div class="image-section">
|
||||||
|
<div class="image-preview-wrap" @click="triggerFileInput">
|
||||||
|
<img v-if="imagePreviewUrl" :src="imagePreviewUrl" class="image-preview" alt="Boat preview" />
|
||||||
|
<div v-else-if="form.img_src" class="image-preview-wrap">
|
||||||
|
<img :src="boatImage.medium(form.img_src)" class="image-preview" alt="Current boat image" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="image-placeholder">
|
||||||
|
<IonIcon :icon="cameraOutline" class="camera-icon" />
|
||||||
|
<span>Tap to upload photo</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="imagePreviewUrl || form.img_src" class="image-overlay">
|
||||||
|
<IonIcon :icon="cameraOutline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
class="hidden-file-input"
|
||||||
|
@change="onFileSelected"
|
||||||
|
/>
|
||||||
|
<p v-if="pendingFile" class="image-hint">{{ pendingFile.name }} — will upload on save</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Name <span class="required">*</span></IonLabel>
|
||||||
|
<IonInput v-model="form.name" placeholder="e.g. J27-1" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Display Name</IonLabel>
|
||||||
|
<IonInput v-model="form.display_name" placeholder="e.g. Blue Heron" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Class</IonLabel>
|
||||||
|
<IonInput v-model="form.class" placeholder="e.g. J/27" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Year</IonLabel>
|
||||||
|
<IonInput v-model.number="form.year" type="number" placeholder="e.g. 1988" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Max Passengers</IonLabel>
|
||||||
|
<IonInput v-model.number="form.max_passengers" type="number" :min="1" :max="20" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel>
|
||||||
|
<h3>Available for Booking</h3>
|
||||||
|
</IonLabel>
|
||||||
|
<IonToggle v-model="form.booking_available" slot="end" color="success" />
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<!-- Required certifications -->
|
||||||
|
<h4 class="section-title ion-margin-top">Required Certifications</h4>
|
||||||
|
<div class="cert-edit-area">
|
||||||
|
<div class="cert-chips-edit" v-if="form.required_certs.length">
|
||||||
|
<span v-for="(c, i) in form.required_certs" :key="i" class="cert-chip-edit">
|
||||||
|
{{ c }}
|
||||||
|
<button class="cert-remove" @click="removeCert(i)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-text-sm">No certifications required.</p>
|
||||||
|
<div class="cert-add-row">
|
||||||
|
<IonInput
|
||||||
|
v-model="newCert"
|
||||||
|
placeholder="Add cert code (e.g. j27)"
|
||||||
|
class="cert-input"
|
||||||
|
@keyup.enter="addCert"
|
||||||
|
/>
|
||||||
|
<IonButton @click="addCert" :disabled="!newCert.trim()" fill="outline">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
:disabled="!form.name.trim() || saving"
|
||||||
|
@click="saveBoat"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="saving" name="crescent" slot="start" />
|
||||||
|
{{ saving ? 'Saving…' : 'Save Boat' }}
|
||||||
|
</IonButton>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
|
||||||
|
<IonAlert
|
||||||
|
:is-open="deleteAlert.show"
|
||||||
|
header="Delete Boat"
|
||||||
|
:message="`Delete '${deleteAlert.name}'? All slots and reservations for this boat will also be deleted.`"
|
||||||
|
:buttons="[
|
||||||
|
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
|
||||||
|
{ text: 'Delete', role: 'destructive', handler: deleteBoat },
|
||||||
|
]"
|
||||||
|
@did-dismiss="deleteAlert.show = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
IonMenuButton, IonButton, IonIcon, IonCard, IonBadge,
|
||||||
|
IonList, IonItem, IonLabel, IonInput, IonToggle, IonSpinner,
|
||||||
|
IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import { addOutline, pencilOutline, trashOutline, boatOutline, cameraOutline } from 'ionicons/icons'
|
||||||
|
import type { Database } from '~/types/supabase'
|
||||||
|
|
||||||
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
|
|
||||||
|
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
const boatImage = useBoatImage()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editing = ref<Boat | null>(null)
|
||||||
|
const boats = ref<Boat[]>([])
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
const deleteAlert = reactive({ show: false, id: '', name: '' })
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const pendingFile = ref<File | null>(null)
|
||||||
|
const imagePreviewUrl = ref<string>('')
|
||||||
|
const newCert = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
class: '',
|
||||||
|
year: null as number | null,
|
||||||
|
max_passengers: 6,
|
||||||
|
booking_available: true,
|
||||||
|
required_certs: [] as string[],
|
||||||
|
img_src: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
watch(user, (val) => { if (val) fetchBoats() }, { immediate: true })
|
||||||
|
onIonViewWillEnter(() => { if (user.value) fetchBoats() })
|
||||||
|
|
||||||
|
async function fetchBoats() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await supabase.from('boats').select('*').order('name')
|
||||||
|
boats.value = data ?? []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
resetForm()
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(boat: Boat) {
|
||||||
|
editing.value = boat
|
||||||
|
form.name = boat.name
|
||||||
|
form.display_name = boat.display_name ?? ''
|
||||||
|
form.class = boat.class ?? ''
|
||||||
|
form.year = boat.year ?? null
|
||||||
|
form.max_passengers = boat.max_passengers
|
||||||
|
form.booking_available = boat.booking_available
|
||||||
|
form.required_certs = [...boat.required_certs]
|
||||||
|
form.img_src = boat.img_src
|
||||||
|
pendingFile.value = null
|
||||||
|
imagePreviewUrl.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editing.value = null
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = ''
|
||||||
|
form.display_name = ''
|
||||||
|
form.class = ''
|
||||||
|
form.year = null
|
||||||
|
form.max_passengers = 6
|
||||||
|
form.booking_available = true
|
||||||
|
form.required_certs = []
|
||||||
|
form.img_src = null
|
||||||
|
pendingFile.value = null
|
||||||
|
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
|
||||||
|
imagePreviewUrl.value = ''
|
||||||
|
newCert.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelected(event: Event) {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
pendingFile.value = file
|
||||||
|
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
|
||||||
|
imagePreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCert() {
|
||||||
|
const c = newCert.value.trim().toLowerCase()
|
||||||
|
if (c && !form.required_certs.includes(c)) {
|
||||||
|
form.required_certs.push(c)
|
||||||
|
}
|
||||||
|
newCert.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCert(index: number) {
|
||||||
|
form.required_certs.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBoat() {
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
display_name: form.display_name.trim() || null,
|
||||||
|
class: form.class.trim() || null,
|
||||||
|
year: form.year || null,
|
||||||
|
max_passengers: form.max_passengers,
|
||||||
|
booking_available: form.booking_available,
|
||||||
|
required_certs: form.required_certs,
|
||||||
|
}
|
||||||
|
|
||||||
|
let boatId = editing.value?.id
|
||||||
|
let error
|
||||||
|
|
||||||
|
if (editing.value) {
|
||||||
|
;({ error } = await supabase.from('boats').update(payload).eq('id', boatId))
|
||||||
|
} else {
|
||||||
|
const { data, error: insertError } = await supabase
|
||||||
|
.from('boats').insert(payload).select('id').single()
|
||||||
|
error = insertError
|
||||||
|
boatId = data?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
saving.value = false
|
||||||
|
showToast('Save failed: ' + error.message, 'danger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload image if a new file was selected
|
||||||
|
if (pendingFile.value && boatId) {
|
||||||
|
const ext = pendingFile.value.name.split('.').pop() ?? 'jpg'
|
||||||
|
const path = `boats/${boatId}/original.${ext}`
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('boat-images')
|
||||||
|
.upload(path, pendingFile.value, { upsert: true, contentType: pendingFile.value.type })
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
saving.value = false
|
||||||
|
showToast('Boat saved but image upload failed: ' + uploadError.message, 'warning')
|
||||||
|
await fetchBoats()
|
||||||
|
closeModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from('boats').update({ img_src: path }).eq('id', boatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
closeModal()
|
||||||
|
await fetchBoats()
|
||||||
|
showToast(editing.value ? 'Boat updated' : 'Boat created', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(boat: Boat) {
|
||||||
|
deleteAlert.id = boat.id
|
||||||
|
deleteAlert.name = boat.display_name || boat.name
|
||||||
|
deleteAlert.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBoat() {
|
||||||
|
deleteAlert.show = false
|
||||||
|
const { error } = await supabase.from('boats').delete().eq('id', deleteAlert.id)
|
||||||
|
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
|
||||||
|
await fetchBoats()
|
||||||
|
showToast('Boat deleted', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, color: string) {
|
||||||
|
toast.message = message
|
||||||
|
toast.color = color
|
||||||
|
toast.show = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.boat-card { margin: 0 0 0.75rem; }
|
||||||
|
.boat-card-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.boat-thumb-wrap { flex-shrink: 0; }
|
||||||
|
.boat-thumb {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.boat-thumb-placeholder {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
.boat-info { flex: 1; min-width: 0; }
|
||||||
|
.boat-name { font-weight: 600; font-size: 1rem; }
|
||||||
|
.boat-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
.cert-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.3rem 0; }
|
||||||
|
.cert-chip {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--ion-color-primary-tint);
|
||||||
|
color: var(--ion-color-primary-shade);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.avail-badge { font-size: 0.7rem; }
|
||||||
|
.boat-actions { display: flex; flex-direction: column; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Modal image section */
|
||||||
|
.image-section { margin-bottom: 1rem; }
|
||||||
|
.image-preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.image-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.camera-icon { font-size: 2.5rem; }
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.image-preview-wrap:hover .image-overlay { opacity: 1; }
|
||||||
|
.hidden-file-input { display: none; }
|
||||||
|
.image-hint { font-size: 0.78rem; color: var(--ion-color-medium); margin: 0.25rem 0 0; }
|
||||||
|
|
||||||
|
.required { color: var(--ion-color-danger); }
|
||||||
|
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||||
|
|
||||||
|
/* Cert editor */
|
||||||
|
.cert-edit-area { margin-bottom: 0.75rem; }
|
||||||
|
.cert-chips-edit { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
|
||||||
|
.cert-chip-edit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--ion-color-primary-tint);
|
||||||
|
color: var(--ion-color-primary-shade);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cert-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ion-color-primary-shade);
|
||||||
|
}
|
||||||
|
.cert-add-row { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.cert-input { flex: 1; }
|
||||||
|
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
|
||||||
|
.empty-text-sm { color: var(--ion-color-medium); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||||
|
</style>
|
||||||
282
app/pages/admin/config.vue
Normal file
282
app/pages/admin/config.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Booking Rules</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton :disabled="!dirty || saving" @click="saveAll">
|
||||||
|
<IonSpinner v-if="saving" name="crescent" slot="icon-only" />
|
||||||
|
<IonIcon v-else slot="icon-only" :icon="saveOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Weekly Limits</IonCardTitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel>
|
||||||
|
<h3>Max sessions per week</h3>
|
||||||
|
<p>Pre-booked sessions allowed per member per ISO week (Mon–Sun)</p>
|
||||||
|
</IonLabel>
|
||||||
|
<IonInput
|
||||||
|
slot="end"
|
||||||
|
type="number"
|
||||||
|
:value="local.max_sessions_per_week"
|
||||||
|
class="config-input"
|
||||||
|
:min="1"
|
||||||
|
:max="14"
|
||||||
|
@ion-input="set('max_sessions_per_week', $event.detail.value)"
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Weekend / Holiday Limits</IonCardTitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel>
|
||||||
|
<h3>Max weekend sessions per period</h3>
|
||||||
|
<p>Max Sat/Sun/holiday pre-bookings per alternating period</p>
|
||||||
|
</IonLabel>
|
||||||
|
<IonInput
|
||||||
|
slot="end"
|
||||||
|
type="number"
|
||||||
|
:value="local.max_weekend_sessions_per_period"
|
||||||
|
class="config-input"
|
||||||
|
:min="1"
|
||||||
|
:max="10"
|
||||||
|
@ion-input="set('max_weekend_sessions_per_period', $event.detail.value)"
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel>
|
||||||
|
<h3>Weekend period length (weeks)</h3>
|
||||||
|
<p>Number of weeks in each alternating period (default: 2 = every other weekend)</p>
|
||||||
|
</IonLabel>
|
||||||
|
<IonInput
|
||||||
|
slot="end"
|
||||||
|
type="number"
|
||||||
|
:value="local.weekend_period_weeks"
|
||||||
|
class="config-input"
|
||||||
|
:min="1"
|
||||||
|
:max="8"
|
||||||
|
@ion-input="set('weekend_period_weeks', $event.detail.value)"
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Open Session Window</IonCardTitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel>
|
||||||
|
<h3>Advance hours for open sessions</h3>
|
||||||
|
<p>Pre-booking limits are waived for sessions starting within this many hours</p>
|
||||||
|
</IonLabel>
|
||||||
|
<IonInput
|
||||||
|
slot="end"
|
||||||
|
type="number"
|
||||||
|
:value="local.open_session_advance_hours"
|
||||||
|
class="config-input"
|
||||||
|
:min="1"
|
||||||
|
:max="72"
|
||||||
|
@ion-input="set('open_session_advance_hours', $event.detail.value)"
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>Holidays</IonCardTitle>
|
||||||
|
<IonCardSubtitle>Counted as weekend days for booking limits</IonCardSubtitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem v-for="h in holidays" :key="h.date">
|
||||||
|
<IonLabel>
|
||||||
|
<h3>{{ h.name }}</h3>
|
||||||
|
<p>{{ h.date }}</p>
|
||||||
|
</IonLabel>
|
||||||
|
<IonButton slot="end" fill="clear" color="danger" @click="deleteHoliday(h.date)">
|
||||||
|
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
<div class="add-holiday-row">
|
||||||
|
<IonInput v-model="newHoliday.date" type="date" placeholder="Date" class="holiday-date-input" />
|
||||||
|
<IonInput v-model="newHoliday.name" placeholder="Name (e.g. Canada Day)" class="holiday-name-input" />
|
||||||
|
<IonButton @click="addHoliday" :disabled="!newHoliday.date || !newHoliday.name">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||||
|
IonCardSubtitle, IonCardContent, IonList, IonItem, IonLabel, IonInput,
|
||||||
|
IonSpinner, IonToast, onIonViewWillEnter,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import { saveOutline, trashOutline, addOutline } from 'ionicons/icons'
|
||||||
|
import type { Database } from '~/types/supabase'
|
||||||
|
|
||||||
|
type ConfigKey = 'max_sessions_per_week' | 'max_weekend_sessions_per_period' | 'weekend_period_weeks' | 'open_session_advance_hours'
|
||||||
|
type Holiday = { date: string; name: string }
|
||||||
|
|
||||||
|
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const dirty = ref(false)
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
const holidays = ref<Holiday[]>([])
|
||||||
|
const newHoliday = reactive({ date: '', name: '' })
|
||||||
|
|
||||||
|
const local = reactive<Record<ConfigKey, number>>({
|
||||||
|
max_sessions_per_week: 2,
|
||||||
|
max_weekend_sessions_per_period: 1,
|
||||||
|
weekend_period_weeks: 2,
|
||||||
|
open_session_advance_hours: 24,
|
||||||
|
})
|
||||||
|
|
||||||
|
const original = reactive<Record<ConfigKey, number>>({ ...local })
|
||||||
|
|
||||||
|
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
|
||||||
|
.from('booking_config')
|
||||||
|
.select('key, value')
|
||||||
|
|
||||||
|
for (const row of (data ?? []) as { key: string; value: unknown }[]) {
|
||||||
|
const k = row.key as ConfigKey
|
||||||
|
if (k in local) {
|
||||||
|
local[k] = Number(row.value)
|
||||||
|
original[k] = Number(row.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHolidays() {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('holidays')
|
||||||
|
.select('date, name')
|
||||||
|
.order('date', { ascending: true })
|
||||||
|
holidays.value = (data ?? []) as Holiday[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(key: ConfigKey, value: string | number | null | undefined) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!isNaN(n) && n > 0) {
|
||||||
|
local[key] = n
|
||||||
|
dirty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAll() {
|
||||||
|
saving.value = true
|
||||||
|
const upserts = (Object.keys(local) as ConfigKey[]).map(k => ({ key: k, value: local[k] }))
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('booking_config')
|
||||||
|
.upsert(upserts, { onConflict: 'key' })
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
|
||||||
|
Object.assign(original, local)
|
||||||
|
dirty.value = false
|
||||||
|
showToast('Booking rules saved', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addHoliday() {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('holidays')
|
||||||
|
.insert({ date: newHoliday.date, name: newHoliday.name })
|
||||||
|
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
|
||||||
|
newHoliday.date = ''
|
||||||
|
newHoliday.name = ''
|
||||||
|
await fetchHolidays()
|
||||||
|
showToast('Holiday added', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHoliday(date: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('holidays')
|
||||||
|
.delete()
|
||||||
|
.eq('date', date)
|
||||||
|
if (error) { showToast('Failed: ' + error.message, 'danger'); return }
|
||||||
|
await fetchHolidays()
|
||||||
|
showToast('Holiday removed', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, color: string) {
|
||||||
|
toast.message = message
|
||||||
|
toast.color = color
|
||||||
|
toast.show = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-input {
|
||||||
|
text-align: right;
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
.add-holiday-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.holiday-date-input { flex: 0 0 160px; }
|
||||||
|
.holiday-name-input { flex: 1; }
|
||||||
|
</style>
|
||||||
444
app/pages/admin/intervals.vue
Normal file
444
app/pages/admin/intervals.vue
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Manage Slots</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="showWeekApply = true">
|
||||||
|
<IonIcon slot="icon-only" :icon="calendarOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton @click="showAddSlot = true">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<!-- Date selector -->
|
||||||
|
<h3 class="section-title">Date</h3>
|
||||||
|
<div class="date-strip">
|
||||||
|
<button
|
||||||
|
v-for="d in dateOptions"
|
||||||
|
:key="d.iso"
|
||||||
|
class="date-chip"
|
||||||
|
:class="{ active: selectedDate === d.iso }"
|
||||||
|
@click="selectedDate = d.iso"
|
||||||
|
>
|
||||||
|
<span class="day-name">{{ d.dayName }}</span>
|
||||||
|
<span class="day-num">{{ d.dayNum }}</span>
|
||||||
|
<span class="month">{{ d.month }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<IonCard v-for="boat in boats" :key="boat.id" class="boat-card">
|
||||||
|
<IonCardHeader>
|
||||||
|
<div class="boat-header">
|
||||||
|
<div>
|
||||||
|
<IonCardTitle>{{ boat.display_name || boat.name }}</IonCardTitle>
|
||||||
|
<IonCardSubtitle v-if="boat.class">{{ boat.class }}</IonCardSubtitle>
|
||||||
|
</div>
|
||||||
|
<IonToggle
|
||||||
|
:checked="boat.booking_available"
|
||||||
|
:enable-on-off-labels="true"
|
||||||
|
color="success"
|
||||||
|
@ion-change="toggleBoatAvailability(boat, $event.detail.checked)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<p v-if="!boat.booking_available" class="out-of-service">
|
||||||
|
<IonIcon :icon="constructOutline" /> Out of service
|
||||||
|
</p>
|
||||||
|
<div class="apply-template-row">
|
||||||
|
<IonSelect
|
||||||
|
placeholder="Apply template..."
|
||||||
|
interface="action-sheet"
|
||||||
|
@ion-change="applyTemplate(boat.id, $event.detail.value)"
|
||||||
|
>
|
||||||
|
<IonSelectOption v-for="t in templates" :key="t.id" :value="t.id">
|
||||||
|
{{ t.name }}
|
||||||
|
</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="slotsByBoat[boat.id]?.length === 0" class="empty-text">No slots for this date.</p>
|
||||||
|
<IonList v-else lines="full">
|
||||||
|
<IonItem v-for="slot in slotsByBoat[boat.id]" :key="slot.id">
|
||||||
|
<IonIcon slot="start" :icon="timeOutline" />
|
||||||
|
<IonLabel>{{ formatTime(slot.start_time) }} – {{ formatTime(slot.end_time) }}</IonLabel>
|
||||||
|
<IonButton
|
||||||
|
slot="end"
|
||||||
|
fill="clear"
|
||||||
|
color="danger"
|
||||||
|
@click="deleteSlot(slot.id)"
|
||||||
|
>
|
||||||
|
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Add slot modal -->
|
||||||
|
<IonModal :is-open="showAddSlot" @did-dismiss="showAddSlot = false">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>Add Slot</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="showAddSlot = false">Cancel</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Boat</IonLabel>
|
||||||
|
<IonSelect v-model="newSlot.boatId" placeholder="Select boat" interface="action-sheet">
|
||||||
|
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
|
||||||
|
{{ b.display_name || b.name }}
|
||||||
|
</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Start Time</IonLabel>
|
||||||
|
<IonInput v-model="newSlot.startTime" type="time" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">End Time</IonLabel>
|
||||||
|
<IonInput v-model="newSlot.endTime" type="time" />
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
:disabled="!newSlot.boatId || !newSlot.startTime || !newSlot.endTime || addingSlot"
|
||||||
|
@click="addSlot"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="addingSlot" name="crescent" slot="start" />
|
||||||
|
Add Slot
|
||||||
|
</IonButton>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
|
||||||
|
<!-- Apply to Week modal -->
|
||||||
|
<IonModal :is-open="showWeekApply" @did-dismiss="showWeekApply = false">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>Apply Template to Week</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="showWeekApply = false">Cancel</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Template</IonLabel>
|
||||||
|
<IonSelect v-model="weekApply.templateId" placeholder="Select template" interface="action-sheet">
|
||||||
|
<IonSelectOption v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Boat</IonLabel>
|
||||||
|
<IonSelect v-model="weekApply.boatId" placeholder="Select boat" interface="action-sheet">
|
||||||
|
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">{{ b.display_name || b.name }}</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<h4 class="section-title ion-margin-top">Days to apply</h4>
|
||||||
|
<IonList lines="none">
|
||||||
|
<IonItem v-for="(day, i) in weekDays" :key="day.iso">
|
||||||
|
<IonCheckbox v-model="weekApply.days[i]" slot="start" />
|
||||||
|
<IonLabel>{{ day.label }}</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
:disabled="!weekApply.templateId || !weekApply.boatId || !weekApply.days.some(Boolean) || weekApply.applying"
|
||||||
|
@click="applyTemplateToWeek"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="weekApply.applying" name="crescent" slot="start" />
|
||||||
|
Apply to Selected Days
|
||||||
|
</IonButton>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
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,
|
||||||
|
} from 'ionicons/icons'
|
||||||
|
import type { Database, TimeTuple } from '~/types/supabase'
|
||||||
|
|
||||||
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
|
type Interval = Database['public']['Tables']['intervals']['Row']
|
||||||
|
type Template = Database['public']['Tables']['interval_templates']['Row']
|
||||||
|
|
||||||
|
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const addingSlot = ref(false)
|
||||||
|
const showAddSlot = ref(false)
|
||||||
|
const showWeekApply = ref(false)
|
||||||
|
const weekApply = reactive({ templateId: '', boatId: '', applying: false, days: [] as boolean[] })
|
||||||
|
|
||||||
|
watch(showWeekApply, (open) => {
|
||||||
|
if (open) weekApply.days = Array(7).fill(true)
|
||||||
|
})
|
||||||
|
const weekDays = computed(() => {
|
||||||
|
const base = new Date(selectedDate.value + 'T12:00:00')
|
||||||
|
const dow = base.getDay() // 0=Sun
|
||||||
|
const monday = new Date(base)
|
||||||
|
monday.setDate(base.getDate() - ((dow + 6) % 7))
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(monday)
|
||||||
|
d.setDate(monday.getDate() + i)
|
||||||
|
return { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' }) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const selectedDate = ref(todayIso())
|
||||||
|
const boats = ref<Boat[]>([])
|
||||||
|
const templates = ref<Template[]>([])
|
||||||
|
const slotsByBoat = ref<Record<string, Interval[]>>({})
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
|
||||||
|
const newSlot = reactive({ boatId: '', startTime: '08:00', endTime: '12:00' })
|
||||||
|
|
||||||
|
const dateOptions = computed(() => {
|
||||||
|
const out = []
|
||||||
|
const base = new Date()
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const d = new Date(base)
|
||||||
|
d.setDate(base.getDate() + i)
|
||||||
|
out.push({
|
||||||
|
iso: toIso(d),
|
||||||
|
dayName: d.toLocaleDateString('en-CA', { weekday: 'short' }),
|
||||||
|
dayNum: d.getDate(),
|
||||||
|
month: d.toLocaleDateString('en-CA', { month: 'short' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([fetchBoats(), fetchTemplates()])
|
||||||
|
await 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')
|
||||||
|
boats.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplates() {
|
||||||
|
const { data } = await supabase.from('interval_templates').select('*').order('name')
|
||||||
|
templates.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSlots() {
|
||||||
|
loading.value = true
|
||||||
|
const dayStart = selectedDate.value + 'T00:00:00Z'
|
||||||
|
const dayEnd = selectedDate.value + 'T23:59:59Z'
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('intervals')
|
||||||
|
.select('*')
|
||||||
|
.gte('start_time', dayStart)
|
||||||
|
.lte('start_time', dayEnd)
|
||||||
|
.order('start_time', { ascending: true })
|
||||||
|
|
||||||
|
const grouped: Record<string, Interval[]> = {}
|
||||||
|
for (const boat of boats.value) grouped[boat.id] = []
|
||||||
|
for (const slot of (data ?? []) as Interval[]) {
|
||||||
|
if (!grouped[slot.boat_id]) grouped[slot.boat_id] = []
|
||||||
|
grouped[slot.boat_id]!.push(slot)
|
||||||
|
}
|
||||||
|
slotsByBoat.value = grouped
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSlot() {
|
||||||
|
addingSlot.value = true
|
||||||
|
const start = new Date(`${selectedDate.value}T${newSlot.startTime}:00`)
|
||||||
|
const end = new Date(`${selectedDate.value}T${newSlot.endTime}:00`)
|
||||||
|
|
||||||
|
const { error } = await supabase.from('intervals').insert({
|
||||||
|
boat_id: newSlot.boatId,
|
||||||
|
start_time: start.toISOString(),
|
||||||
|
end_time: end.toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
addingSlot.value = false
|
||||||
|
if (error) {
|
||||||
|
showToast('Failed to add slot: ' + error.message, 'danger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showAddSlot.value = false
|
||||||
|
newSlot.boatId = ''
|
||||||
|
await fetchSlots()
|
||||||
|
showToast('Slot added', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSlot(id: string) {
|
||||||
|
const { error } = await supabase.from('intervals').delete().eq('id', id)
|
||||||
|
if (error) { showToast('Failed to delete: ' + error.message, 'danger'); return }
|
||||||
|
await fetchSlots()
|
||||||
|
showToast('Slot removed', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBoatAvailability(boat: Boat, available: boolean) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('boats')
|
||||||
|
.update({ booking_available: available })
|
||||||
|
.eq('id', boat.id)
|
||||||
|
if (error) { showToast('Update failed: ' + error.message, 'danger'); return }
|
||||||
|
boat.booking_available = available
|
||||||
|
showToast(available ? 'Boat back in service' : 'Boat out of service', available ? 'success' : 'warning')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTemplate(boatId: string, templateId: string) {
|
||||||
|
const template = templates.value.find(t => t.id === templateId)
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const inserts = (template.time_tuples as TimeTuple[]).map(([start, end]) => ({
|
||||||
|
boat_id: boatId,
|
||||||
|
start_time: new Date(`${selectedDate.value}T${start}:00`).toISOString(),
|
||||||
|
end_time: new Date(`${selectedDate.value}T${end}:00`).toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { error } = await supabase.from('intervals').insert(inserts)
|
||||||
|
if (error) { showToast('Failed to apply template: ' + error.message, 'danger'); return }
|
||||||
|
await fetchSlots()
|
||||||
|
showToast(`Template "${template.name}" applied`, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTemplateToWeek() {
|
||||||
|
const template = templates.value.find(t => t.id === weekApply.templateId)
|
||||||
|
if (!template) return
|
||||||
|
weekApply.applying = true
|
||||||
|
|
||||||
|
const selectedDays = weekDays.value.filter((_, i) => weekApply.days[i])
|
||||||
|
const inserts = selectedDays.flatMap(day =>
|
||||||
|
(template.time_tuples as TimeTuple[]).map(([start, end]) => ({
|
||||||
|
boat_id: weekApply.boatId,
|
||||||
|
start_time: new Date(`${day.iso}T${start}:00`).toISOString(),
|
||||||
|
end_time: new Date(`${day.iso}T${end}:00`).toISOString(),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const { error } = await supabase.from('intervals').insert(inserts)
|
||||||
|
weekApply.applying = false
|
||||||
|
if (error) { showToast('Failed to apply: ' + error.message, 'danger'); return }
|
||||||
|
showWeekApply.value = false
|
||||||
|
await fetchSlots()
|
||||||
|
showToast(`Template applied to ${selectedDays.length} day(s)`, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, color: string) {
|
||||||
|
toast.message = message
|
||||||
|
toast.color = color
|
||||||
|
toast.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso() { return toIso(new Date()) }
|
||||||
|
function toIso(d: Date) { return d.toISOString().slice(0, 10) }
|
||||||
|
function formatTime(iso: string) {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-title { font-size: 1rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||||
|
|
||||||
|
.date-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.date-strip::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--ion-color-light-shade);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.date-chip.active {
|
||||||
|
background: var(--ion-color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
.day-name { font-weight: 600; text-transform: uppercase; font-size: 0.65rem; }
|
||||||
|
.day-num { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.month { font-size: 0.65rem; }
|
||||||
|
|
||||||
|
.boat-card { margin: 0 0 1rem; }
|
||||||
|
.boat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.out-of-service {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--ion-color-danger);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-template-row { margin-bottom: 0.75rem; }
|
||||||
|
|
||||||
|
.empty-text { color: var(--ion-color-medium); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
390
app/pages/admin/reservations.vue
Normal file
390
app/pages/admin/reservations.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Manage Bookings</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="openCreate">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filter-row">
|
||||||
|
<IonSelect
|
||||||
|
v-model="filterBoatId"
|
||||||
|
placeholder="All boats"
|
||||||
|
interface="action-sheet"
|
||||||
|
class="boat-filter"
|
||||||
|
>
|
||||||
|
<IonSelectOption value="">All boats</IonSelectOption>
|
||||||
|
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
|
||||||
|
{{ b.display_name || b.name }}
|
||||||
|
</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
<IonInput v-model="filterDateFrom" type="date" class="date-filter" />
|
||||||
|
<IonInput v-model="filterDateTo" type="date" class="date-filter" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="filteredReservations.length === 0" class="empty-text">No bookings found.</p>
|
||||||
|
|
||||||
|
<IonCard v-else v-for="r in filteredReservations" :key="r.id" class="res-card">
|
||||||
|
<IonCardContent>
|
||||||
|
<div class="res-header">
|
||||||
|
<div class="res-main">
|
||||||
|
<div class="res-boat">{{ boatName(r.boat_id) }}</div>
|
||||||
|
<div class="res-member">{{ authStore.getUserNameById(r.user_id) }}</div>
|
||||||
|
<div class="res-time">
|
||||||
|
{{ formatDate(r.start_time) }}
|
||||||
|
{{ formatTime(r.start_time) }}–{{ formatTime(r.end_time) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="r.reason" class="res-reason">{{ r.reason }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="res-right">
|
||||||
|
<IonBadge :color="statusColor(r.status)" class="status-badge">{{ r.status }}</IonBadge>
|
||||||
|
<div class="res-actions">
|
||||||
|
<IonButton fill="clear" @click="openEdit(r)">
|
||||||
|
<IonIcon slot="icon-only" :icon="pencilOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton fill="clear" color="danger" @click="confirmDelete(r)">
|
||||||
|
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<!-- Create / Edit modal -->
|
||||||
|
<IonModal :is-open="showModal" @did-dismiss="closeModal">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{{ editing ? 'Edit Booking' : 'New Booking' }}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="closeModal">Cancel</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Boat <span class="required">*</span></IonLabel>
|
||||||
|
<IonSelect v-model="form.boat_id" placeholder="Select boat" interface="action-sheet">
|
||||||
|
<IonSelectOption v-for="b in boats" :key="b.id" :value="b.id">
|
||||||
|
{{ b.display_name || b.name }}
|
||||||
|
</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Member <span class="required">*</span></IonLabel>
|
||||||
|
<IonSelect v-model="form.user_id" placeholder="Select member" interface="action-sheet">
|
||||||
|
<IonSelectOption v-for="m in members" :key="m.user_id" :value="m.user_id">
|
||||||
|
{{ m.first_name }} {{ m.last_name }} ({{ m.email }})
|
||||||
|
</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Date <span class="required">*</span></IonLabel>
|
||||||
|
<IonInput v-model="form.date" type="date" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Start Time <span class="required">*</span></IonLabel>
|
||||||
|
<IonInput v-model="form.startTime" type="time" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">End Time <span class="required">*</span></IonLabel>
|
||||||
|
<IonInput v-model="form.endTime" type="time" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Status</IonLabel>
|
||||||
|
<IonSelect v-model="form.status" interface="action-sheet">
|
||||||
|
<IonSelectOption value="pending">Pending</IonSelectOption>
|
||||||
|
<IonSelectOption value="tentative">Tentative</IonSelectOption>
|
||||||
|
<IonSelectOption value="confirmed">Confirmed</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Reason</IonLabel>
|
||||||
|
<IonInput v-model="form.reason" placeholder="e.g. Day sail" />
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Comment</IonLabel>
|
||||||
|
<IonTextarea v-model="form.comment" :rows="2" placeholder="Optional admin note" />
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
:disabled="!formValid || saving"
|
||||||
|
@click="saveReservation"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="saving" name="crescent" slot="start" />
|
||||||
|
{{ saving ? 'Saving…' : 'Save Booking' }}
|
||||||
|
</IonButton>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
|
||||||
|
<IonAlert
|
||||||
|
:is-open="deleteAlert.show"
|
||||||
|
header="Delete Booking"
|
||||||
|
message="Delete this booking? This cannot be undone."
|
||||||
|
:buttons="[
|
||||||
|
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
|
||||||
|
{ text: 'Delete', role: 'destructive', handler: deleteReservation },
|
||||||
|
]"
|
||||||
|
@did-dismiss="deleteAlert.show = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
IonMenuButton, IonButton, IonIcon, IonCard, IonCardContent, IonBadge,
|
||||||
|
IonList, IonItem, IonLabel, IonInput, IonTextarea, IonSelect, IonSelectOption,
|
||||||
|
IonSpinner, IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import { addOutline, pencilOutline, trashOutline } from 'ionicons/icons'
|
||||||
|
import type { Database } from '~/types/supabase'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
|
type Reservation = Database['public']['Tables']['reservations']['Row']
|
||||||
|
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 authStore = useAuthStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editing = ref<Reservation | null>(null)
|
||||||
|
const reservations = ref<Reservation[]>([])
|
||||||
|
const boats = ref<Boat[]>([])
|
||||||
|
const members = ref<Member[]>([])
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
const deleteAlert = reactive({ show: false, id: '' })
|
||||||
|
|
||||||
|
// Filters — default to current week ± 4 weeks
|
||||||
|
const today = new Date()
|
||||||
|
const filterDateFrom = ref(toIso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - 14)))
|
||||||
|
const filterDateTo = ref(toIso(new Date(today.getFullYear(), today.getMonth(), today.getDate() + 28)))
|
||||||
|
const filterBoatId = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
boat_id: '',
|
||||||
|
user_id: '',
|
||||||
|
date: toIso(today),
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '13:00',
|
||||||
|
status: 'confirmed' as 'pending' | 'tentative' | 'confirmed',
|
||||||
|
reason: '',
|
||||||
|
comment: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formValid = computed(() =>
|
||||||
|
!!form.boat_id && !!form.user_id && !!form.date && !!form.startTime && !!form.endTime
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredReservations = computed(() => {
|
||||||
|
return reservations.value.filter(r => {
|
||||||
|
if (filterBoatId.value && r.boat_id !== filterBoatId.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([fetchBoats(), fetchMembers()])
|
||||||
|
await 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')
|
||||||
|
boats.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMembers() {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('members')
|
||||||
|
.select('*')
|
||||||
|
.order('last_name')
|
||||||
|
members.value = data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReservations() {
|
||||||
|
loading.value = true
|
||||||
|
const from = filterDateFrom.value + 'T00:00:00Z'
|
||||||
|
const to = filterDateTo.value + 'T23:59:59Z'
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('reservations')
|
||||||
|
.select('*')
|
||||||
|
.gte('start_time', from)
|
||||||
|
.lte('start_time', to)
|
||||||
|
.order('start_time', { ascending: true })
|
||||||
|
|
||||||
|
reservations.value = data ?? []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
form.boat_id = ''
|
||||||
|
form.user_id = ''
|
||||||
|
form.date = toIso(today)
|
||||||
|
form.startTime = '09:00'
|
||||||
|
form.endTime = '13:00'
|
||||||
|
form.status = 'confirmed'
|
||||||
|
form.reason = ''
|
||||||
|
form.comment = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r: Reservation) {
|
||||||
|
editing.value = r
|
||||||
|
form.boat_id = r.boat_id
|
||||||
|
form.user_id = r.user_id
|
||||||
|
form.date = r.start_time.slice(0, 10)
|
||||||
|
form.startTime = new Date(r.start_time).toTimeString().slice(0, 5)
|
||||||
|
form.endTime = new Date(r.end_time).toTimeString().slice(0, 5)
|
||||||
|
form.status = r.status as 'pending' | 'tentative' | 'confirmed'
|
||||||
|
form.reason = r.reason
|
||||||
|
form.comment = r.comment
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReservation() {
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
boat_id: form.boat_id,
|
||||||
|
user_id: form.user_id,
|
||||||
|
start_time: new Date(`${form.date}T${form.startTime}:00`).toISOString(),
|
||||||
|
end_time: new Date(`${form.date}T${form.endTime}:00`).toISOString(),
|
||||||
|
status: form.status,
|
||||||
|
reason: form.reason,
|
||||||
|
comment: form.comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
let error
|
||||||
|
if (editing.value) {
|
||||||
|
;({ error } = await supabase.from('reservations').update(payload).eq('id', editing.value.id))
|
||||||
|
} else {
|
||||||
|
;({ error } = await supabase.from('reservations').insert(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
await fetchReservations()
|
||||||
|
showToast(editing.value ? 'Booking updated' : 'Booking created', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(r: Reservation) {
|
||||||
|
deleteAlert.id = r.id
|
||||||
|
deleteAlert.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReservation() {
|
||||||
|
deleteAlert.show = false
|
||||||
|
const { error } = await supabase.from('reservations').delete().eq('id', deleteAlert.id)
|
||||||
|
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
|
||||||
|
await fetchReservations()
|
||||||
|
showToast('Booking deleted', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function boatName(id: string) {
|
||||||
|
const b = boats.value.find(b => b.id === id)
|
||||||
|
return b ? (b.display_name || b.name) : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string) {
|
||||||
|
return status === 'confirmed' ? 'success' : status === 'tentative' ? 'primary' : 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string) {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(d: Date) { return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
|
function showToast(message: string, color: string) {
|
||||||
|
toast.message = message
|
||||||
|
toast.color = color
|
||||||
|
toast.show = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.boat-filter { flex: 1; min-width: 120px; }
|
||||||
|
.date-filter { flex: 1; min-width: 130px; }
|
||||||
|
|
||||||
|
.res-card { margin: 0 0 0.6rem; }
|
||||||
|
.res-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.res-main { flex: 1; min-width: 0; }
|
||||||
|
.res-boat { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.res-member { font-size: 0.85rem; color: var(--ion-color-medium); }
|
||||||
|
.res-time { font-size: 0.85rem; margin: 0.15rem 0; }
|
||||||
|
.res-reason { font-size: 0.8rem; color: var(--ion-color-medium); font-style: italic; }
|
||||||
|
.res-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.status-badge { font-size: 0.7rem; }
|
||||||
|
.res-actions { display: flex; }
|
||||||
|
|
||||||
|
.required { color: var(--ion-color-danger); }
|
||||||
|
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
|
||||||
|
</style>
|
||||||
264
app/pages/admin/templates.vue
Normal file
264
app/pages/admin/templates.vue
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Interval Templates</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="openCreate">
|
||||||
|
<IonIcon slot="icon-only" :icon="addOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="templates.length === 0" class="empty-text">No templates yet. Create one to get started.</p>
|
||||||
|
<IonCard v-else v-for="t in templates" :key="t.id" class="template-card">
|
||||||
|
<IonCardHeader>
|
||||||
|
<div class="template-header">
|
||||||
|
<IonCardTitle>{{ t.name }}</IonCardTitle>
|
||||||
|
<div class="header-actions">
|
||||||
|
<IonButton fill="clear" @click="openEdit(t)">
|
||||||
|
<IonIcon slot="icon-only" :icon="pencilOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton fill="clear" color="danger" @click="confirmDelete(t)">
|
||||||
|
<IonIcon slot="icon-only" :icon="trashOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<div class="time-tuple-list">
|
||||||
|
<span v-for="(tuple, i) in (t.time_tuples as TimeTuple[])" :key="i" class="time-tuple-chip">
|
||||||
|
{{ tuple[0] }}–{{ tuple[1] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<!-- Create/Edit modal -->
|
||||||
|
<IonModal :is-open="showModal" @did-dismiss="closeModal">
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{{ editing ? 'Edit Template' : 'New Template' }}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="closeModal">Cancel</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<IonList lines="full">
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Template Name</IonLabel>
|
||||||
|
<IonInput v-model="form.name" placeholder="e.g. Weekday Standard" />
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<h4 class="section-title ion-margin-top">Time Slots</h4>
|
||||||
|
<div
|
||||||
|
v-for="(tuple, i) in form.tuples"
|
||||||
|
:key="i"
|
||||||
|
class="tuple-row"
|
||||||
|
>
|
||||||
|
<IonInput
|
||||||
|
class="time-input"
|
||||||
|
type="time"
|
||||||
|
:value="tuple[0]"
|
||||||
|
@ion-input="tuple[0] = ($event.detail.value as string)"
|
||||||
|
/>
|
||||||
|
<span class="tuple-dash">–</span>
|
||||||
|
<IonInput
|
||||||
|
class="time-input"
|
||||||
|
type="time"
|
||||||
|
:value="tuple[1]"
|
||||||
|
@ion-input="tuple[1] = ($event.detail.value as string)"
|
||||||
|
/>
|
||||||
|
<IonButton fill="clear" color="danger" @click="removeTuple(i)">
|
||||||
|
<IonIcon slot="icon-only" :icon="closeOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton expand="block" fill="outline" class="ion-margin-top" @click="addTuple">
|
||||||
|
<IonIcon slot="start" :icon="addOutline" />
|
||||||
|
Add Time Slot
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
:disabled="!form.name || form.tuples.length === 0 || saving"
|
||||||
|
@click="saveTemplate"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="saving" name="crescent" slot="start" />
|
||||||
|
Save Template
|
||||||
|
</IonButton>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
|
||||||
|
<IonAlert
|
||||||
|
:is-open="deleteAlert.show"
|
||||||
|
header="Delete Template"
|
||||||
|
:message="`Delete '${deleteAlert.name}'? This cannot be undone.`"
|
||||||
|
:buttons="[
|
||||||
|
{ text: 'Cancel', role: 'cancel', handler: () => { deleteAlert.show = false } },
|
||||||
|
{ text: 'Delete', role: 'destructive', handler: () => deleteTemplate() },
|
||||||
|
]"
|
||||||
|
@did-dismiss="deleteAlert.show = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
IonMenuButton, IonButton, IonIcon, IonCard, IonCardHeader, IonCardTitle,
|
||||||
|
IonCardContent, IonList, IonItem, IonLabel, IonInput, IonSpinner,
|
||||||
|
IonModal, IonAlert, IonToast, onIonViewWillEnter,
|
||||||
|
} from '@ionic/vue'
|
||||||
|
import { addOutline, pencilOutline, trashOutline, closeOutline } from 'ionicons/icons'
|
||||||
|
import type { Database, TimeTuple } from '~/types/supabase'
|
||||||
|
|
||||||
|
type Template = Database['public']['Tables']['interval_templates']['Row']
|
||||||
|
|
||||||
|
definePageMeta({ layout: false, middleware: ['auth'] })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editing = ref<Template | null>(null)
|
||||||
|
const templates = ref<Template[]>([])
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
const deleteAlert = reactive({ show: false, id: '', name: '' })
|
||||||
|
|
||||||
|
const form = reactive<{ name: string; tuples: [string, string][] }>({
|
||||||
|
name: '',
|
||||||
|
tuples: [['08:00', '12:00']],
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
watch(user, (val) => { if (val) fetchTemplates() }, { immediate: true })
|
||||||
|
onIonViewWillEnter(() => { if (user.value) fetchTemplates() })
|
||||||
|
|
||||||
|
async function fetchTemplates() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await supabase.from('interval_templates').select('*').order('name')
|
||||||
|
templates.value = data ?? []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
form.name = ''
|
||||||
|
form.tuples = [['08:00', '12:00']]
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(t: Template) {
|
||||||
|
editing.value = t
|
||||||
|
form.name = t.name
|
||||||
|
form.tuples = (t.time_tuples as TimeTuple[]).map(tuple => [tuple[0], tuple[1]])
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTuple() {
|
||||||
|
form.tuples.push(['13:00', '17:00'])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTuple(i: number) {
|
||||||
|
form.tuples.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate() {
|
||||||
|
saving.value = true
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
time_tuples: form.tuples as TimeTuple[],
|
||||||
|
}
|
||||||
|
|
||||||
|
let error
|
||||||
|
if (editing.value) {
|
||||||
|
;({ error } = await supabase.from('interval_templates').update(payload).eq('id', editing.value.id))
|
||||||
|
} else {
|
||||||
|
;({ error } = await supabase.from('interval_templates').insert(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
if (error) { showToast('Save failed: ' + error.message, 'danger'); return }
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
await fetchTemplates()
|
||||||
|
showToast(editing.value ? 'Template updated' : 'Template created', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(t: Template) {
|
||||||
|
deleteAlert.id = t.id
|
||||||
|
deleteAlert.name = t.name
|
||||||
|
deleteAlert.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTemplate() {
|
||||||
|
const { error } = await supabase.from('interval_templates').delete().eq('id', deleteAlert.id)
|
||||||
|
deleteAlert.show = false
|
||||||
|
if (error) { showToast('Delete failed: ' + error.message, 'danger'); return }
|
||||||
|
await fetchTemplates()
|
||||||
|
showToast('Template deleted', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, color: string) {
|
||||||
|
toast.message = message
|
||||||
|
toast.color = color
|
||||||
|
toast.show = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.template-card { margin: 0 0 1rem; }
|
||||||
|
.template-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header-actions { display: flex; }
|
||||||
|
.time-tuple-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.time-tuple-chip {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
border: 1px solid var(--ion-color-light-shade);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.section-title { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||||
|
.tuple-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.time-input { flex: 1; }
|
||||||
|
.tuple-dash { font-weight: 600; }
|
||||||
|
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
|
||||||
|
</style>
|
||||||
@@ -35,7 +35,13 @@
|
|||||||
No upcoming reservations.
|
No upcoming reservations.
|
||||||
</p>
|
</p>
|
||||||
<IonList v-else lines="full">
|
<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>
|
<IonLabel>
|
||||||
<h3>{{ boatName(r) }}</h3>
|
<h3>{{ boatName(r) }}</h3>
|
||||||
<p>{{ formatDateRange(r.start_time, r.end_time) }}</p>
|
<p>{{ formatDateRange(r.start_time, r.end_time) }}</p>
|
||||||
@@ -71,9 +77,9 @@ import {
|
|||||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||||
IonButtons, IonMenuButton, IonButton, IonCard, IonCardHeader,
|
IonButtons, IonMenuButton, IonButton, IonCard, IonCardHeader,
|
||||||
IonCardTitle, IonCardContent, IonList, IonItem, IonLabel,
|
IonCardTitle, IonCardContent, IonList, IonItem, IonLabel,
|
||||||
IonBadge, IonSpinner, IonIcon,
|
IonBadge, IonSpinner, IonIcon, actionSheetController, onIonViewWillEnter,
|
||||||
} from '@ionic/vue'
|
} from '@ionic/vue'
|
||||||
import { addCircleOutline } from 'ionicons/icons'
|
import { addCircleOutline, createOutline, trashOutline } from 'ionicons/icons'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import type { Database, ReservationStatus } from '~/types/supabase'
|
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||||
|
|
||||||
@@ -86,6 +92,7 @@ definePageMeta({ layout: false })
|
|||||||
const user = useSupabaseUser()
|
const user = useSupabaseUser()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const supabase = useSupabaseClient<Database>()
|
const supabase = useSupabaseClient<Database>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const loadingReservations = ref(true)
|
const loadingReservations = ref(true)
|
||||||
@@ -99,13 +106,47 @@ async function fetchReservations() {
|
|||||||
.select('*, boats(name, display_name)')
|
.select('*, boats(name, display_name)')
|
||||||
.eq('user_id', user.value.id)
|
.eq('user_id', user.value.id)
|
||||||
.gte('start_time', new Date().toISOString())
|
.gte('start_time', new Date().toISOString())
|
||||||
|
.neq('status', 'cancelled')
|
||||||
.order('start_time', { ascending: true })
|
.order('start_time', { ascending: true })
|
||||||
.limit(3)
|
.limit(3)
|
||||||
upcomingReservations.value = (data as ReservationWithBoat[]) ?? []
|
upcomingReservations.value = (data as ReservationWithBoat[]) ?? []
|
||||||
loadingReservations.value = false
|
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 })
|
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) {
|
function boatName(r: ReservationWithBoat) {
|
||||||
return r.boats?.display_name || r.boats?.name || 'Unknown boat'
|
return r.boats?.display_name || r.boats?.name || 'Unknown boat'
|
||||||
@@ -122,9 +163,10 @@ function formatDateRange(start: string, end: string) {
|
|||||||
|
|
||||||
function statusColor(status: ReservationStatus): string {
|
function statusColor(status: ReservationStatus): string {
|
||||||
const colors: Record<ReservationStatus, string> = {
|
const colors: Record<ReservationStatus, string> = {
|
||||||
pending: 'warning',
|
pending: 'warning',
|
||||||
tentative: 'medium',
|
tentative: 'medium',
|
||||||
confirmed: 'success',
|
confirmed: 'success',
|
||||||
|
cancelled: 'danger',
|
||||||
}
|
}
|
||||||
return colors[status]
|
return colors[status]
|
||||||
}
|
}
|
||||||
|
|||||||
465
app/pages/reservations/create.vue
Normal file
465
app/pages/reservations/create.vue
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton default-href="/" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>New Reservation</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent class="ion-padding">
|
||||||
|
<!-- Step 1: Pick a date and select a slot -->
|
||||||
|
<template v-if="step === 1">
|
||||||
|
<h3 class="section-title">Select Date</h3>
|
||||||
|
|
||||||
|
<!-- Horizontal date strip -->
|
||||||
|
<div class="date-strip">
|
||||||
|
<button
|
||||||
|
v-for="d in dateOptions"
|
||||||
|
:key="d.iso"
|
||||||
|
class="date-chip"
|
||||||
|
:class="{ active: selectedDate === d.iso }"
|
||||||
|
@click="selectedDate = d.iso"
|
||||||
|
>
|
||||||
|
<span class="day-name">{{ d.dayName }}</span>
|
||||||
|
<span class="day-num">{{ d.dayNum }}</span>
|
||||||
|
<span class="month">{{ d.month }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title ion-margin-top">Available Slots</h3>
|
||||||
|
|
||||||
|
<div v-if="loadingSlots" class="ion-text-center ion-padding">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="availableByBoat.length === 0" class="empty-text">
|
||||||
|
No available slots for this date.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<IonCard
|
||||||
|
v-for="entry in availableByBoat"
|
||||||
|
:key="entry.boat.id"
|
||||||
|
class="boat-card"
|
||||||
|
>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>{{ entry.boat.display_name || entry.boat.name }}</IonCardTitle>
|
||||||
|
<IonCardSubtitle v-if="entry.boat.class">{{ entry.boat.class }}</IonCardSubtitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<div v-if="!entry.certified" class="cert-warning">
|
||||||
|
<IonIcon :icon="warningOutline" color="warning" />
|
||||||
|
<span>You are not certified for this boat.</span>
|
||||||
|
</div>
|
||||||
|
<div class="slot-chips">
|
||||||
|
<button
|
||||||
|
v-for="slot in entry.slots"
|
||||||
|
:key="slot.id"
|
||||||
|
class="slot-chip"
|
||||||
|
:disabled="!entry.certified"
|
||||||
|
@click="selectSlot(entry.boat, slot)"
|
||||||
|
>
|
||||||
|
{{ formatSlotTime(slot.start_time) }}–{{ formatSlotTime(slot.end_time) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 2: Confirm details -->
|
||||||
|
<template v-else>
|
||||||
|
<IonCard>
|
||||||
|
<IonCardHeader>
|
||||||
|
<IonCardTitle>{{ selectedBoat?.display_name || selectedBoat?.name }}</IonCardTitle>
|
||||||
|
<IonCardSubtitle v-if="selectedBoat?.class">{{ selectedBoat.class }}</IonCardSubtitle>
|
||||||
|
</IonCardHeader>
|
||||||
|
<IonCardContent>
|
||||||
|
<div class="time-summary">
|
||||||
|
<div class="time-row">
|
||||||
|
<IonIcon :icon="timeOutline" />
|
||||||
|
<span>{{ formatFull(selectedSlot!.start_time) }} – {{ formatSlotTime(selectedSlot!.end_time) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
|
||||||
|
<IonList lines="full" class="ion-margin-top">
|
||||||
|
<IonItem 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">
|
||||||
|
<IonSelectOption v-for="r in reasonOptions" :key="r" :value="r">{{ r }}</IonSelectOption>
|
||||||
|
</IonSelect>
|
||||||
|
</IonItem>
|
||||||
|
<IonItem>
|
||||||
|
<IonLabel position="stacked">Additional Comments (optional)</IonLabel>
|
||||||
|
<IonTextarea v-model="form.comment" :rows="3" placeholder="Any notes..." />
|
||||||
|
</IonItem>
|
||||||
|
</IonList>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<IonButton fill="outline" @click="step = 1">Back</IonButton>
|
||||||
|
<IonButton
|
||||||
|
:disabled="!form.reason || submitting"
|
||||||
|
@click="submitReservation"
|
||||||
|
>
|
||||||
|
<IonSpinner v-if="submitting" name="crescent" slot="start" />
|
||||||
|
Confirm Booking
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonToast
|
||||||
|
v-model:is-open="toast.show"
|
||||||
|
:message="toast.message"
|
||||||
|
:color="toast.color"
|
||||||
|
:duration="3000"
|
||||||
|
position="bottom"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButtons,
|
||||||
|
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
||||||
|
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
|
||||||
|
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
|
||||||
|
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']
|
||||||
|
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const { take: takeDraft } = useBookingDraft()
|
||||||
|
const cache = useAppCache()
|
||||||
|
const { isOnline } = useOfflineStatus()
|
||||||
|
|
||||||
|
const step = ref<1 | 2>(1)
|
||||||
|
const loadingSlots = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const selectedDate = ref(todayIso())
|
||||||
|
const selectedBoat = ref<Boat | null>(null)
|
||||||
|
const selectedSlot = ref<Interval | null>(null)
|
||||||
|
const members = ref<Member[]>([])
|
||||||
|
const toast = reactive({ show: false, message: '', color: 'success' })
|
||||||
|
|
||||||
|
const form = reactive({ reason: '', comment: '', targetUserId: '' })
|
||||||
|
const reasonOptions = ['Open Sail', 'Private Sail', 'Racing', 'Training', 'Other']
|
||||||
|
|
||||||
|
// 14-day date strip starting today
|
||||||
|
const dateOptions = computed(() => {
|
||||||
|
const out = []
|
||||||
|
const base = new Date()
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const d = new Date(base)
|
||||||
|
d.setDate(base.getDate() + i)
|
||||||
|
out.push({
|
||||||
|
iso: toIso(d),
|
||||||
|
dayName: d.toLocaleDateString('en-CA', { weekday: 'short' }),
|
||||||
|
dayNum: d.getDate(),
|
||||||
|
month: d.toLocaleDateString('en-CA', { month: 'short' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
interface BoatSlotEntry {
|
||||||
|
boat: Boat
|
||||||
|
slots: Interval[]
|
||||||
|
certified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableByBoat = ref<BoatSlotEntry[]>([])
|
||||||
|
|
||||||
|
// 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: draft.boat.id,
|
||||||
|
start_time: draft.startTime,
|
||||||
|
end_time: draft.endTime,
|
||||||
|
user_id: null,
|
||||||
|
created_at: '',
|
||||||
|
}
|
||||||
|
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 ?? []
|
||||||
|
}
|
||||||
|
await loadSlots()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedDate, loadSlots)
|
||||||
|
|
||||||
|
async function loadSlots() {
|
||||||
|
loadingSlots.value = true
|
||||||
|
availableByBoat.value = []
|
||||||
|
|
||||||
|
const dayStart = selectedDate.value + 'T00:00:00Z'
|
||||||
|
const dayEnd = selectedDate.value + 'T23:59:59Z'
|
||||||
|
const wk = cache.weekKey(selectedDate.value + 'T12:00:00Z')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 BookedSlot[]) {
|
||||||
|
booked.add(`${r.boat_id}|${r.start_time}|${r.end_time}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCerts: string[] = auth.member?.certifications ?? []
|
||||||
|
|
||||||
|
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 as { start_time: string }).start_time}|${(row as { end_time: string }).end_time}`
|
||||||
|
if (booked.has(key)) continue
|
||||||
|
|
||||||
|
if (!byBoat.has(boat.id)) {
|
||||||
|
byBoat.set(boat.id, {
|
||||||
|
boat,
|
||||||
|
slots: [],
|
||||||
|
certified: boat.required_certs.length === 0 ||
|
||||||
|
boat.required_certs.every(c => memberCerts.includes(c)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
byBoat.get(boat.id)!.slots.push(row as Interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
availableByBoat.value = Array.from(byBoat.values())
|
||||||
|
loadingSlots.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSlot(boat: Boat, slot: Interval) {
|
||||||
|
selectedBoat.value = boat
|
||||||
|
selectedSlot.value = slot
|
||||||
|
form.reason = ''
|
||||||
|
form.comment = ''
|
||||||
|
step.value = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReservation() {
|
||||||
|
if (!auth.user || !selectedBoat.value || !selectedSlot.value) return
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
const { data: sessionData } = await supabase.auth.getSession()
|
||||||
|
const accessToken = sessionData.session?.access_token ?? ''
|
||||||
|
|
||||||
|
const response = await supabase.functions.invoke('create-reservation', {
|
||||||
|
body: {
|
||||||
|
boat_id: selectedBoat.value.id,
|
||||||
|
start_time: selectedSlot.value.start_time,
|
||||||
|
end_time: selectedSlot.value.end_time,
|
||||||
|
reason: form.reason,
|
||||||
|
comment: form.comment,
|
||||||
|
...(auth.isAdmin && form.targetUserId ? { target_user_id: form.targetUserId } : {}),
|
||||||
|
},
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
submitting.value = false
|
||||||
|
|
||||||
|
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'
|
||||||
|
toast.show = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.message = 'Reservation created!'
|
||||||
|
toast.color = 'success'
|
||||||
|
toast.show = true
|
||||||
|
setTimeout(() => router.push('/'), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
return toIso(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(d: Date) {
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSlotTime(iso: string) {
|
||||||
|
return new Date(iso).toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFull(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.date-strip::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--ion-color-light-shade);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.date-chip.active {
|
||||||
|
background: var(--ion-color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
.day-name { font-weight: 600; text-transform: uppercase; font-size: 0.65rem; }
|
||||||
|
.day-num { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.month { font-size: 0.65rem; }
|
||||||
|
|
||||||
|
.boat-card { margin: 0 0 1rem; }
|
||||||
|
|
||||||
|
.cert-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--ion-color-warning-shade);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.slot-chip {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--ion-color-primary);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.slot-chip:disabled {
|
||||||
|
border-color: var(--ion-color-medium);
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.slot-chip:not(:disabled):hover {
|
||||||
|
background: var(--ion-color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-summary { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.time-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.empty-text { color: var(--ion-color-medium); text-align: center; padding: 2rem 0; }
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
577
app/pages/schedule.vue
Normal file
577
app/pages/schedule.vue
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
<template>
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader>
|
||||||
|
<IonToolbar color="primary">
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonMenuButton />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Schedule</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton @click="goToday" :disabled="focusDate === todayToronto()">
|
||||||
|
<IonIcon slot="icon-only" :icon="todayOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent>
|
||||||
|
<!-- Date navigation bar -->
|
||||||
|
<div class="date-nav">
|
||||||
|
<IonButton fill="clear" @click="navPrev">
|
||||||
|
<IonIcon slot="icon-only" :icon="chevronBackOutline" />
|
||||||
|
</IonButton>
|
||||||
|
<span class="date-nav-label" v-if="isMobile">{{ fmtDateLong(focusDate) }}</span>
|
||||||
|
<span class="date-nav-label" v-else>
|
||||||
|
{{ fmtDayHeader(visibleDates[0]!).weekday }} {{ fmtDayHeader(visibleDates[0]!).short }}
|
||||||
|
–
|
||||||
|
{{ fmtDayHeader(visibleDates[6]!).weekday }} {{ fmtDayHeader(visibleDates[6]!).short }}
|
||||||
|
</span>
|
||||||
|
<IonButton fill="clear" @click="navNext">
|
||||||
|
<IonIcon slot="icon-only" :icon="chevronForwardOutline" />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="ion-text-center ion-padding-top" style="padding: 3rem 0">
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mobile: single-day stacked view ─────────────────────── -->
|
||||||
|
<div v-else-if="isMobile" class="mobile-schedule">
|
||||||
|
<div v-for="boat in boats" :key="boat.id" class="mobile-boat-card">
|
||||||
|
<div class="mobile-boat-header">
|
||||||
|
<div class="mobile-boat-info">
|
||||||
|
<span class="mobile-boat-name">{{ boat.display_name || boat.name }}</span>
|
||||||
|
<span v-if="boat.class" class="mobile-boat-class">{{ boat.class }}</span>
|
||||||
|
</div>
|
||||||
|
<IonBadge v-if="!boat.booking_available" color="medium">Out of service</IonBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!boat.booking_available" class="slots-empty">Not available for booking.</div>
|
||||||
|
<div v-else-if="daySlots(boat.id, focusDate).length === 0" class="slots-empty">No slots today.</div>
|
||||||
|
<div v-else class="mobile-slots">
|
||||||
|
<div
|
||||||
|
v-for="slot in daySlots(boat.id, focusDate)"
|
||||||
|
:key="slot.id"
|
||||||
|
class="slot-block"
|
||||||
|
: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)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Desktop: week grid ───────────────────────────────────── -->
|
||||||
|
<div v-else class="desktop-schedule-wrap">
|
||||||
|
<div class="desktop-schedule">
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="grid-header-row">
|
||||||
|
<div class="grid-boat-col grid-header-cell"></div>
|
||||||
|
<div
|
||||||
|
v-for="date in visibleDates"
|
||||||
|
:key="date"
|
||||||
|
class="grid-day-col grid-header-cell"
|
||||||
|
:class="{ 'is-today': date === todayToronto() }"
|
||||||
|
>
|
||||||
|
<span class="grid-day-weekday">{{ fmtDayHeader(date).weekday }}</span>
|
||||||
|
<span class="grid-day-short">{{ fmtDayHeader(date).short }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One row per boat -->
|
||||||
|
<div v-for="boat in boats" :key="boat.id" class="grid-data-row">
|
||||||
|
<div class="grid-boat-col grid-boat-label">
|
||||||
|
<div class="grid-boat-name">{{ boat.display_name || boat.name }}</div>
|
||||||
|
<div v-if="boat.class" class="grid-boat-class">{{ boat.class }}</div>
|
||||||
|
<IonBadge v-if="!boat.booking_available" color="medium" class="oos-badge">OOS</IonBadge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="date in visibleDates"
|
||||||
|
:key="date"
|
||||||
|
class="grid-day-col grid-cell"
|
||||||
|
:class="{ 'is-today': date === todayToronto(), 'cell-oos': !boat.booking_available }"
|
||||||
|
>
|
||||||
|
<template v-if="boat.booking_available">
|
||||||
|
<div v-if="daySlots(boat.id, date).length === 0" class="cell-empty">—</div>
|
||||||
|
<div v-else class="cell-slots">
|
||||||
|
<div
|
||||||
|
v-for="slot in daySlots(boat.id, date)"
|
||||||
|
:key="slot.id"
|
||||||
|
class="slot-block slot-sm"
|
||||||
|
: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) }}-{{ fmtTime(slot.endTime) }}</span>
|
||||||
|
<span class="slot-status-sm">{{ slotLabelShort(slot) }}</span>
|
||||||
|
<span class="slot-member-sm">{{ slot.memberName ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="cell-empty">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-item slot-block slot-available">Available</span>
|
||||||
|
<span class="legend-item slot-block slot-confirmed">Confirmed</span>
|
||||||
|
<span class="legend-item slot-block slot-tentative">Tentative</span>
|
||||||
|
<span class="legend-item slot-block slot-pending">Pending</span>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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']
|
||||||
|
type SlotView = Database['public']['Views']['reservation_slots']['Row']
|
||||||
|
|
||||||
|
interface SlotBlock {
|
||||||
|
id: string
|
||||||
|
boatId: string
|
||||||
|
startTime: string
|
||||||
|
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)
|
||||||
|
function checkBreakpoint() { isMobile.value = window.innerWidth < 768 }
|
||||||
|
onMounted(() => { checkBreakpoint(); window.addEventListener('resize', checkBreakpoint) })
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', checkBreakpoint))
|
||||||
|
|
||||||
|
// ── Date state ──────────────────────────────────────────────
|
||||||
|
const focusDate = ref(todayToronto())
|
||||||
|
|
||||||
|
const visibleDates = computed(() =>
|
||||||
|
isMobile.value ? [focusDate.value] : weekDates(focusDate.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
function navPrev() {
|
||||||
|
focusDate.value = isMobile.value
|
||||||
|
? addDays(focusDate.value, -1)
|
||||||
|
: addDays(focusDate.value, -7)
|
||||||
|
}
|
||||||
|
function navNext() {
|
||||||
|
focusDate.value = isMobile.value
|
||||||
|
? addDays(focusDate.value, +1)
|
||||||
|
: addDays(focusDate.value, +7)
|
||||||
|
}
|
||||||
|
function goToday() { focusDate.value = todayToronto() }
|
||||||
|
|
||||||
|
// ── Data ────────────────────────────────────────────────────
|
||||||
|
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 wk = cache.weekKey(dates[0]!)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await fetchBoats()
|
||||||
|
await 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[] {
|
||||||
|
const dayIntervals = intervals.value
|
||||||
|
.filter(i =>
|
||||||
|
i.boat_id === boatId &&
|
||||||
|
toDateToronto(new Date(i.start_time)) === dateIso
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||||
|
|
||||||
|
return dayIntervals.map(interval => {
|
||||||
|
const res = slotViews.value.find(r =>
|
||||||
|
r.boat_id === boatId &&
|
||||||
|
r.start_time >= interval.start_time &&
|
||||||
|
r.start_time < interval.end_time
|
||||||
|
)
|
||||||
|
if (res) {
|
||||||
|
return {
|
||||||
|
id: res.id,
|
||||||
|
boatId,
|
||||||
|
startTime: interval.start_time,
|
||||||
|
endTime: interval.end_time,
|
||||||
|
type: 'booked' as const,
|
||||||
|
status: res.status,
|
||||||
|
isOwn: myReservationIds.value.has(res.id),
|
||||||
|
memberName: res.member_name ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: interval.id,
|
||||||
|
boatId,
|
||||||
|
startTime: interval.start_time,
|
||||||
|
endTime: interval.end_time,
|
||||||
|
type: 'available' as const,
|
||||||
|
status: null,
|
||||||
|
isOwn: false,
|
||||||
|
memberName: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display helpers ──────────────────────────────────────────
|
||||||
|
function slotClass(slot: SlotBlock) {
|
||||||
|
return slot.type === 'available' ? 'slot-available' : `slot-${slot.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabel(slot: SlotBlock): string {
|
||||||
|
if (slot.type === 'available') return 'Available'
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
tentative: 'Tentative',
|
||||||
|
pending: 'Pending',
|
||||||
|
}
|
||||||
|
return labels[slot.status ?? ''] ?? 'Booked'
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabelShort(slot: SlotBlock): string {
|
||||||
|
if (slot.type === 'available') return 'Free'
|
||||||
|
return slotLabel(slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookSlot(slot: SlotBlock) {
|
||||||
|
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 {
|
||||||
|
return `${fmtTime(slot.startTime)}–${fmtTime(slot.endTime)} · ${slotLabel(slot)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Date navigation bar ───────────────────────────────────── */
|
||||||
|
.date-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.date-nav-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Slot block base ────────────────────────────────────────── */
|
||||||
|
.slot-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 56px;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.slot-available { background: var(--ion-color-success); color: #fff; }
|
||||||
|
.slot-confirmed { background: var(--ion-color-medium); color: #fff; }
|
||||||
|
.slot-tentative { background: var(--ion-color-primary); color: #fff; }
|
||||||
|
.slot-pending { background: var(--ion-color-warning); color: #333; }
|
||||||
|
|
||||||
|
.slot-main-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
transition: filter 0.12s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.slot-tappable:active {
|
||||||
|
filter: brightness(0.88);
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile schedule ────────────────────────────────────────── */
|
||||||
|
.mobile-schedule { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
|
||||||
|
.mobile-boat-card {
|
||||||
|
border: 1px solid var(--ion-color-light-shade);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mobile-boat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.mobile-boat-info { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||||
|
.mobile-boat-name { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.mobile-boat-class { font-size: 0.78rem; color: var(--ion-color-medium); }
|
||||||
|
|
||||||
|
.mobile-slots {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.slots-empty {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop schedule ───────────────────────────────────────── */
|
||||||
|
.desktop-schedule-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-schedule {
|
||||||
|
display: grid;
|
||||||
|
/* boat-label column + 7 day columns */
|
||||||
|
grid-template-columns: 130px repeat(7, minmax(110px, 1fr));
|
||||||
|
min-width: 900px;
|
||||||
|
border-top: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-header-row,
|
||||||
|
.grid-data-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-header-cell {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
border-bottom: 2px solid var(--ion-color-light-shade);
|
||||||
|
border-right: 1px solid var(--ion-color-light-shade);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.grid-day-col.grid-header-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.grid-day-weekday { font-size: 0.72rem; color: var(--ion-color-medium); text-transform: uppercase; font-weight: 600; }
|
||||||
|
.grid-day-short { font-size: 0.85rem; font-weight: 600; }
|
||||||
|
|
||||||
|
.grid-header-cell.is-today,
|
||||||
|
.grid-cell.is-today {
|
||||||
|
background: color-mix(in srgb, var(--ion-color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-boat-col {
|
||||||
|
border-right: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.grid-boat-label {
|
||||||
|
padding: 0.6rem 0.65rem;
|
||||||
|
background: var(--ion-color-light-tint);
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.grid-boat-name { font-weight: 600; font-size: 0.85rem; }
|
||||||
|
.grid-boat-class { font-size: 0.72rem; color: var(--ion-color-medium); }
|
||||||
|
.oos-badge { font-size: 0.65rem; margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
padding: 0.4rem 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--ion-color-light-shade);
|
||||||
|
border-right: 1px solid var(--ion-color-light-shade);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.cell-empty { color: var(--ion-color-light-shade); text-align: center; font-size: 1.1rem; padding: 0.5rem 0; }
|
||||||
|
.cell-oos { background: repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(0,0,0,0.03) 4px, rgba(0,0,0,0.03) 8px); }
|
||||||
|
.cell-slots { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
|
||||||
|
/* Compact slot variant for desktop cells */
|
||||||
|
.slot-sm {
|
||||||
|
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.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 {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--ion-color-light-shade);
|
||||||
|
}
|
||||||
|
.legend-item { font-size: 0.75rem; padding: 0.2rem 0.6rem; }
|
||||||
|
</style>
|
||||||
@@ -8,7 +8,7 @@ export type Json =
|
|||||||
|
|
||||||
// Domain types
|
// Domain types
|
||||||
export type MemberRole = 'member' | 'skipper' | 'admin' | 'boatswain' | 'volunteer' | 'instructor'
|
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 {
|
export interface Defect {
|
||||||
type: string
|
type: string
|
||||||
severity: string
|
severity: string
|
||||||
@@ -190,6 +190,37 @@ export type Database = {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
booking_config: {
|
||||||
|
Row: {
|
||||||
|
key: string
|
||||||
|
value: Json
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
key: string
|
||||||
|
value: Json
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
key?: string
|
||||||
|
value?: Json
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
holidays: {
|
||||||
|
Row: {
|
||||||
|
date: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
date: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
date?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
reference_docs: {
|
reference_docs: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string
|
id: string
|
||||||
@@ -221,7 +252,17 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Views: {
|
Views: {
|
||||||
[_ in never]: never
|
reservation_slots: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
boat_id: string
|
||||||
|
user_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
status: ReservationStatus
|
||||||
|
member_name: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
83
app/utils/toronto.ts
Normal file
83
app/utils/toronto.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const TZ = 'America/Toronto'
|
||||||
|
|
||||||
|
/** YYYY-MM-DD for right now in Toronto */
|
||||||
|
export function todayToronto(): string {
|
||||||
|
return toDateToronto(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in Toronto for any Date */
|
||||||
|
export function toDateToronto(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', { timeZone: TZ }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add n calendar days to a YYYY-MM-DD string.
|
||||||
|
* Uses noon UTC so the shift is always exactly 24 h and never crosses
|
||||||
|
* a Toronto DST boundary ambiguously.
|
||||||
|
*/
|
||||||
|
export function addDays(isoDate: string, n: number): string {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00Z')
|
||||||
|
d.setUTCDate(d.getUTCDate() + n)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HH:MM in Toronto for a UTC ISO string */
|
||||||
|
export function fmtTime(utcIso: string): string {
|
||||||
|
return new Date(utcIso).toLocaleTimeString('en-CA', {
|
||||||
|
timeZone: TZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Wednesday, April 20" style label for a YYYY-MM-DD string */
|
||||||
|
export function fmtDateLong(isoDate: string): string {
|
||||||
|
return new Date(isoDate + 'T12:00:00Z').toLocaleDateString('en-CA', {
|
||||||
|
timeZone: TZ,
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** { weekday: "Wed", short: "Apr 20" } for a YYYY-MM-DD string */
|
||||||
|
export function fmtDayHeader(isoDate: string): { weekday: string; short: string } {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00Z')
|
||||||
|
return {
|
||||||
|
weekday: d.toLocaleDateString('en-CA', { timeZone: TZ, weekday: 'short' }),
|
||||||
|
short: d.toLocaleDateString('en-CA', { timeZone: TZ, month: 'short', day: 'numeric' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YYYY-MM-DD of the Monday that starts the ISO week containing isoDate.
|
||||||
|
* Uses the en-CA weekday name to determine day-of-week in Toronto.
|
||||||
|
*/
|
||||||
|
export function weekMonday(isoDate: string): string {
|
||||||
|
const d = new Date(isoDate + 'T12:00:00Z')
|
||||||
|
const wd = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, weekday: 'short' }).format(d)
|
||||||
|
const offsets: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 }
|
||||||
|
const offset = offsets[wd] ?? 0
|
||||||
|
const monday = new Date(d)
|
||||||
|
monday.setUTCDate(d.getUTCDate() - offset)
|
||||||
|
return monday.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All 7 YYYY-MM-DD strings for the ISO week containing isoDate (Mon → Sun) */
|
||||||
|
export function weekDates(isoDate: string): string[] {
|
||||||
|
const mon = weekMonday(isoDate)
|
||||||
|
return Array.from({ length: 7 }, (_, i) => addDays(mon, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC range string pair that safely covers all Toronto-local timestamps
|
||||||
|
* falling within the isoFrom…isoTo date range.
|
||||||
|
* Adds a 1-day buffer on each side so client-side date filtering is authoritative.
|
||||||
|
*/
|
||||||
|
export function utcRange(isoFrom: string, isoTo: string): { from: string; to: string } {
|
||||||
|
return {
|
||||||
|
from: addDays(isoFrom, -1) + 'T00:00:00Z',
|
||||||
|
to: addDays(isoTo, +1) + 'T23:59:59Z',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Session Handoff: Edge Functions, Auth Pattern, and Test Fixes
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Session Duration:** ~2 hours
|
||||||
|
**Session Focus:** Fix create-reservation Edge Function auth, resolve 12 failing integration tests, fix RBAC RLS, add SELinux dev docs
|
||||||
|
**Context Usage at Handoff:** ~60%
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
1. Diagnosed and fixed SELinux blocking Edge Functions locally → documented fix in `CLAUDE.md`
|
||||||
|
2. Updated Edge Function auth from `userClient.auth.getUser()` (anon key + auth header) to `adminClient.auth.getUser(token)` (service role + JWT arg) → `supabase/functions/create-reservation/index.ts`
|
||||||
|
3. Fixed `weekSlot()` test helper returning `{start, end}` instead of `{start_time, end_time}` → `tests/integration/booking-constraints.test.ts`
|
||||||
|
4. Fixed overlap tests using days 30/31/32 (same ISO week, hitting weekly pre-booking limit before DB overlap constraint fires) → spread across days 14/21/28 (different weeks)
|
||||||
|
5. Fixed RBAC: `"Authenticated users can read non-private reservation slots"` policy on `reservations` was never dropped when `reservation_slots` view was created → new migration drops it
|
||||||
|
6. Fixed `reservation_slots` view from `security_invoker = true` to `security_invoker = false` so it reads as owner, not caller → new migration recreates view + grants
|
||||||
|
7. Fixed E2E test asserting `"Welcome to OYS Borrow a Boat"` (doesn't exist) → changed to `"Upcoming Reservations"` which is always present when authenticated
|
||||||
|
8. Added `v-if="authStore.user"` to `IonMenu` in `app.vue` — menu not rendered before login
|
||||||
|
9. Added `yarn test:all` script (unit → integration → e2e in sequence)
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
- **Use `adminClient.auth.getUser(token)`** (not `getClaims`) BECAUSE `getClaims` is not reliably available in `npm:@supabase/supabase-js@2` Deno import and its return shape is undocumented for that context — STATUS: confirmed
|
||||||
|
- **`reservation_slots` view uses `security_invoker = false`** BECAUSE `security_invoker = true` caused it to apply the calling user's RLS (returning 0 rows for non-owners after broad policy was dropped) — STATUS: confirmed
|
||||||
|
- **Overlap tests use weeks 14/21/28 days ahead** BECAUSE original days 30/31/32 fell in the same ISO week; direct insert on day+31 consumed the 2nd weekly pre-booking slot, blocking the day+32 "different time" test — STATUS: confirmed
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
- Integration tests before: 12 failed / 8 passed (20 total)
|
||||||
|
- Integration tests after: 0 failed / 20 passed (ASSUMED — verify with `yarn test:integration`)
|
||||||
|
- E2E tests: 1 failed / 1 passed → 2 passed after auth text fix (ASSUMED — verify with `yarn test:e2e`)
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `supabase/functions/create-reservation/index.ts` | Modified | Auth: `getClaims` → `adminClient.auth.getUser(token)`; `claims.sub` → `user.id` |
|
||||||
|
| `tests/integration/booking-constraints.test.ts` | Modified | `weekSlot` key names fixed; overlap test days spread across weeks |
|
||||||
|
| `tests/e2e/auth.spec.ts` | Modified | Assertion changed from missing text to `"Upcoming Reservations"` |
|
||||||
|
| `app/app.vue` | Modified | `v-if="authStore.user"` on `IonMenu` |
|
||||||
|
| `package.json` | Modified | Added `test:all` script |
|
||||||
|
| `CLAUDE.md` | Modified | Added Edge Functions section: SELinux fix, auth pattern, `security_invoker` note |
|
||||||
|
| `supabase/migrations/20260420180000_drop_open_reservations_read_policy.sql` | Created | Drops `"Authenticated users can read non-private reservation slots"` policy |
|
||||||
|
| `supabase/migrations/20260420190000_fix_reservation_slots_view.sql` | Created | Recreates `reservation_slots` with `security_invoker = false`; grants SELECT to authenticated |
|
||||||
|
| `supabase/migrations/20260420132336_booking_rules_and_rbac.sql` | Modified | Fixed original view creation to `security_invoker = false` + added GRANT for `db reset` consistency |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
1. **First**: Verify all tests pass — `yarn test:all` (requires local Supabase running with functions served)
|
||||||
|
2. **Then**: Work on reservations UI — `app/pages/reservations/` exists but contents unknown; likely needs create/list/detail pages wired to the Edge Function
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
- [ ] What pages exist under `app/pages/reservations/`? Are they scaffolded or complete? — impacts next UI session scope
|
||||||
|
- [ ] Are there additional Edge Functions planned (e.g., cancel-reservation, admin endpoints)? — impacts function auth pattern reuse
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
- ASSUMED: `yarn test:all` passes cleanly after migrations applied — validate by running `npx supabase migration up && yarn test:all`
|
||||||
|
- ASSUMED: `reservation_slots` view grant is sufficient for anon client queries in tests — validate by observing RBAC test pass
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
- `docs/summaries/handoff-2026-04-20-edge-functions-auth-and-test-fixes.md` — this file
|
||||||
|
- `supabase/functions/create-reservation/index.ts` — if continuing Edge Function work
|
||||||
|
- `app/pages/reservations/` — if working on reservations UI
|
||||||
73
docs/archive/handoffs/handoff-2026-04-20-home-page.md
Normal file
73
docs/archive/handoffs/handoff-2026-04-20-home-page.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Session Handoff: Home Page Content + E2E Fixes
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Session Duration:** ~1.5 hours
|
||||||
|
**Session Focus:** Build authenticated home page; fix E2E test issues from prior session
|
||||||
|
**Context Usage at Handoff:** Low
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. **Playwright E2E: fixed selector issues** — `IonButton router-link` renders as role=`link` not `button`; `IonInput` label not associated via `for`/`id`, use `getByPlaceholder` instead
|
||||||
|
2. **Playwright E2E: fixed magic link redirect** — `supabase/config.toml` had wrong `site_url` (`127.0.0.1` → `localhost`) and missing `/auth/callback` in `additional_redirect_urls`; also added server-side redirect follow via `fetch(magicLink, { redirect: 'manual' })` to avoid browser connecting to Supabase local port
|
||||||
|
3. **E2E tests passing: 2/2** — splash→login→magic link→home flow + protected route redirect
|
||||||
|
4. **Home page built** → `app/pages/index.vue` — Welcome heading, upcoming reservations card, Create Reservation button, inline PrimeVue DatePicker calendar
|
||||||
|
5. **WeatherWidget component created** → `app/components/WeatherWidget.vue` — Open-Meteo (free, no key), shows temp/conditions/wind in knots; 10-minute `localStorage` cache to avoid API overload
|
||||||
|
6. **Marina coordinates set**: `43.4412629, -79.6696725` (Oakville/Bronte Harbour)
|
||||||
|
7. **`IonSplitPane` added** to `app/app.vue` — sidebar open by default on desktop (≥992px); user reverted `auto-hide="false"` on `IonMenuButton` in `index.vue` — desktop sidebar toggle behavior is OPEN
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- Desktop sidebar toggle: `IonSplitPane` is in `app.vue` but `auto-hide="false"` was reverted from `index.vue`; sidebar opens on desktop but hamburger icon hides — revisit next session if needed
|
||||||
|
- `/reservations/create` route: wired up as `router-link` on Create Reservation button but page does not exist yet
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- **Open-Meteo for weather** BECAUSE free, no API key, client-side CORS allowed, updates every 15 min — STATUS: confirmed
|
||||||
|
- **10-minute `localStorage` cache for weather** BECAUSE Open-Meteo updates every 15 min; survives page refresh — STATUS: confirmed
|
||||||
|
- **Server-side redirect follow for E2E magic link** BECAUSE Playwright Chromium headless shell cannot reach `127.0.0.1:54321` (Supabase local auth); Node.js `fetch` can — STATUS: confirmed
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- Weather cache TTL: 10 minutes (`600_000` ms)
|
||||||
|
- Open-Meteo wind unit: knots (`wind_speed_unit=kn`)
|
||||||
|
- Marina coordinates: `43.4412629, -79.6696725`
|
||||||
|
- `IonSplitPane` desktop breakpoint: `lg` = 992px (Ionic default)
|
||||||
|
- E2E tests: 2/2 passing
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `app/pages/index.vue` | Modified | Full home content: welcome, reservations card, create button, calendar |
|
||||||
|
| `app/components/WeatherWidget.vue` | Created | Open-Meteo weather card with localStorage cache |
|
||||||
|
| `app/app.vue` | Modified | Added `IonSplitPane` wrapping menu + router outlet |
|
||||||
|
| `supabase/config.toml` | Modified | `site_url` → `http://localhost:3000`; added `/auth/callback` to `additional_redirect_urls` |
|
||||||
|
| `tests/e2e/auth.spec.ts` | Modified | Fixed selectors; added server-side redirect follow for magic link |
|
||||||
|
| `docs/summaries/handoff-2026-04-19-playwright-e2e-setup.md` | Created | Prior session handoff (Playwright setup) |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **Build `app/pages/reservations/create.vue`** — reservation creation form
|
||||||
|
- Fields needed: boat selection, date/time (start + end), reason, passengers (member_ids)
|
||||||
|
- Schema ref: `app/types/supabase.ts` → `reservations` table Insert type
|
||||||
|
- On submit: `supabase.from('reservations').insert(...)` with `user_id = user.value.id`, default `status = 'pending'`
|
||||||
|
- On success: navigate to `/` or a reservation detail page
|
||||||
|
2. Read `app/types/supabase.ts` for boats + reservations schema before writing the form
|
||||||
|
3. Read `app/stores/auth.ts` for `user` ref and `member` data (member_ids on the reservation)
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] **Desktop hamburger behaviour** — `IonSplitPane` is active but user reverted `auto-hide="false"`; should the hamburger be visible on desktop to allow closing the sidebar, or hidden when sidebar is open? Impacts `IonMenuButton` props on every page
|
||||||
|
- [ ] **Reservation form: boat selection** — should the user pick from all `booking_available = true` boats, or filtered by their certifications (`member.certifications` vs `boat.required_certs`)? Impacts query in create form
|
||||||
|
- [ ] **Reservation form: time selection** — free-form time pickers, or constrained to `interval_templates`? Impacts form UI significantly
|
||||||
|
- [ ] **After reservation created** — navigate to reservation detail, home page, or show confirmation inline?
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: `supabase stop && supabase start` was run after `config.toml` change — E2E tests pass so this is confirmed
|
||||||
|
- ASSUMED: PrimeVue `DatePicker` inline renders correctly in Ionic card on mobile — not manually tested in browser since session end
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `app/types/supabase.ts` — boats + reservations schema for form fields
|
||||||
|
- `app/stores/auth.ts` — user/member refs for form submission
|
||||||
|
- `app/pages/index.vue` — reference for page structure pattern
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Session Handoff: Integration Test Debugging
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Session Duration:** ~1.5 hours
|
||||||
|
**Session Focus:** Debug and fix failing integration tests for auth and booking-constraints suites; fix Edge Function boot failure; fix members RLS infinite recursion
|
||||||
|
**Context Usage at Handoff:** High
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. **Fixed `verifyOtp` API misuse in both test files** — `token` param is for 6-digit OTP codes; `token_hash` param is for hashed magic link tokens. Changed in both test files.
|
||||||
|
- `tests/integration/auth-session.test.ts` (2 call sites)
|
||||||
|
- `tests/integration/booking-constraints.test.ts` (1 call site in `getSessionToken`)
|
||||||
|
|
||||||
|
2. **Fixed `futureSlot()` key mismatch** in booking-constraints test — returned `{ start, end }` but `callCreateReservation` expected `{ start_time, end_time }`. Renamed keys in `futureSlot()` return value; updated 2 `directInsertReservation` call sites that used `slot.start`/`slot.end`.
|
||||||
|
|
||||||
|
3. **Added `@types/node` and test tsconfig** — Nuxt-generated tsconfig has `"types": []`, causing `process` to be unresolved in test files.
|
||||||
|
- Added `@types/node` as dev dependency
|
||||||
|
- Created `tests/tsconfig.json` extending root with `"types": ["node", "vitest/globals"]`
|
||||||
|
- Added `typecheck: { tsconfig: './tests/tsconfig.json' }` to `vitest.integration.config.ts`
|
||||||
|
|
||||||
|
4. **Fixed members RLS infinite recursion (42P17)** — Policies "Admins can read all members" and "Admins can manage all members" queried `members` from within a `members` RLS policy, causing infinite recursion. Created new migration with two `SECURITY DEFINER` helper functions:
|
||||||
|
- `public.current_user_role() → text`
|
||||||
|
- `public.current_user_has_role(roles text[]) → boolean`
|
||||||
|
- Replaced all inline `EXISTS (SELECT 1 FROM members WHERE ...)` admin checks in ALL tables with `public.current_user_has_role(...)` calls
|
||||||
|
- Migration: `supabase/migrations/20260420170418_fix_members_rls_recursion.sql`
|
||||||
|
|
||||||
|
5. **Removed broken import from Edge Function** — `import "@supabase/functions-js/edge-runtime.d.ts"` is not a valid Deno runtime import. Removed it. Also tried `/// <reference types="..." />` which also failed.
|
||||||
|
|
||||||
|
6. **Switched Edge Function import from `jsr:` to `npm:`** — `jsr:@supabase/supabase-js@2` cache is lost on container restart in the podman-based local dev setup. Changed to `npm:@supabase/supabase-js@2`.
|
||||||
|
|
||||||
|
7. **Removed `entrypoint` from config.toml** — `entrypoint` field may not be supported/correctly handled by supabase-edge-runtime 1.73.3 (podman variant). Removed it; function now relies on default `index.ts` convention.
|
||||||
|
|
||||||
|
8. **Set `verify_jwt = false` in config.toml** — Edge runtime 1.73.3 `verifyHybridJWT` fails with `TypeError: Invalid Token or Protected Header formatting` on ES256 JWTs (new `sb_publishable_*` / `sb_secret_*` key format). Disabled edge-level JWT verification; the function handles auth itself via `userClient.auth.getUser()`.
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- **Edge Function still returns BOOT_ERROR** — Even after all the above changes, the edge runtime container still fails with `worker boot error: failed to bootstrap runtime: failed to determine entrypoint`. Investigation showed the function source files are NOT mounted inside the container (`find / -name index.ts` inside the container showed only Deno npm cache, no project files). Root cause: likely a podman volume mount issue specific to this host environment. Session was interrupted before resolving this.
|
||||||
|
|
||||||
|
- **Integration tests NOT yet passing** — All 15 booking-constraints tests still fail with 503 (Edge Function BOOT_ERROR). The auth-session tests (5 tests) status is unknown for this session.
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- **`verify_jwt = false` for create-reservation** BECAUSE: edge-runtime 1.73.3 with podman does not correctly verify ES256 JWTs from new `sb_publishable_*` key format; function verifies auth internally via `auth.getUser()` anyway — STATUS: confirmed
|
||||||
|
- **`npm:` over `jsr:` for supabase-js import** BECAUSE: JSR cache is not persistent across podman container restarts in this local dev setup — STATUS: confirmed
|
||||||
|
- **SECURITY DEFINER functions for role checks** BECAUSE: inline `EXISTS (SELECT 1 FROM members ...)` in members RLS policies causes infinite recursion — STATUS: confirmed, migration applied
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- supabase-js SDK version installed: **2.100.0**
|
||||||
|
- Supabase CLI version: **2.92.1**
|
||||||
|
- supabase-edge-runtime version: **1.73.3** (compatible with Deno v2.1.4)
|
||||||
|
- Publishable key prefix: `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH`
|
||||||
|
- Secret key prefix: `sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz`
|
||||||
|
- Access tokens are **ES256** (not HS256) — `eyJhbGciOiJFUzI1NiIs...`
|
||||||
|
- New migration timestamp: **20260420170418**
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `tests/integration/auth-session.test.ts` | Modified | Fixed `verifyOtp` to use `token_hash` (2 call sites) |
|
||||||
|
| `tests/integration/booking-constraints.test.ts` | Modified | Fixed `verifyOtp` to use `token_hash`; renamed `futureSlot` keys to `start_time`/`end_time`; removed unused `beforeEach` import |
|
||||||
|
| `tests/tsconfig.json` | Created | Test-scoped tsconfig adding `node` + `vitest/globals` types |
|
||||||
|
| `vitest.integration.config.ts` | Modified | Added `typecheck.tsconfig` pointing to `tests/tsconfig.json` |
|
||||||
|
| `supabase/migrations/20260420170418_fix_members_rls_recursion.sql` | Created | SECURITY DEFINER helpers + fixed RLS policies for all tables |
|
||||||
|
| `supabase/functions/create-reservation/index.ts` | Modified | Removed bad `import`; changed `jsr:` → `npm:` for supabase-js |
|
||||||
|
| `supabase/functions/create-reservation/deno.json` | Modified | Updated import map to `npm:@supabase/supabase-js@^2` |
|
||||||
|
| `supabase/config.toml` | Modified | `verify_jwt = false`; removed `entrypoint` field |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **Diagnose Edge Function mount issue first** — Run `podman inspect supabase_edge_runtime_oysqn.app` and check the `Mounts` array. The function source files (`supabase/functions/`) must be visible inside the container. If they are not mounted, this is likely a podman rootless socket or SELinux label issue on the host. Try `npx supabase stop && npx supabase start` to re-create the container with correct mounts.
|
||||||
|
|
||||||
|
2. **If supabase restart doesn't fix mounts** — Try `npx supabase functions serve` in a separate terminal (it spins up its own Deno process outside Docker, bypassing the container). Then run `yarn test:integration` — this tests the exact same function code via the same URL. If this works, the issue is confirmed as a container mount problem, not a code problem.
|
||||||
|
|
||||||
|
3. **After Edge Function works** — Run `yarn test:integration` and expect all 15 booking-constraints tests + all auth-session tests to pass. Fix any remaining failures (expected to be minor after the above fixes).
|
||||||
|
|
||||||
|
4. **Then**: build `/admin/reservations` view — list all upcoming reservations with boat/time/member/status; ability to confirm, cancel, or modify
|
||||||
|
|
||||||
|
5. **Then**: test full member booking flow in browser (`yarn dev`, create test intervals via admin, attempt booking as member, verify Edge Function error messages surface correctly)
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] **Why are supabase function files not mounted in podman container?** — May require `--security-opt label=disable` or `:z` volume label for SELinux compatibility. Impacts all Edge Function development.
|
||||||
|
- [ ] **Adjacent-session double-booking (Rule 6)** — exact logic needed (carried over from previous session)
|
||||||
|
- [ ] **`useSupabaseClient` typing** — all admin pages use `as any` cast (carried over)
|
||||||
|
- [ ] **Crew requirement (Rule 8)** — not implemented (carried over)
|
||||||
|
- [ ] **PCOC / cert code strings** — values for `boats.required_certs` not defined (carried over)
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: `npm:@supabase/supabase-js@2` is resolvable in the podman edge runtime container (npm cache may also be missing) — validate by getting the function to boot
|
||||||
|
- ASSUMED: `verify_jwt = false` is acceptable for production deployment (auth is validated in function code) — validate with security review before prod
|
||||||
|
- ASSUMED: `tests/tsconfig.json` approach for `@types/node` is the right fix and doesn't break other test types — validate by running full test suite
|
||||||
|
- All assumptions from previous session still apply (half-open interval semantics, ISO week definition, 2026-01-05 period epoch)
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `supabase/functions/create-reservation/index.ts` — to debug boot issue or extend
|
||||||
|
- `supabase/config.toml` — to review/revert `verify_jwt` setting after boot is fixed
|
||||||
|
- `supabase/migrations/20260420170418_fix_members_rls_recursion.sql` — if RLS issues resurface
|
||||||
|
- `tests/integration/booking-constraints.test.ts` — to run and review after boot fix
|
||||||
135
docs/archive/handoffs/handoff-2026-04-20-reservations.md
Normal file
135
docs/archive/handoffs/handoff-2026-04-20-reservations.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Session Handoff: Boat Reservation System
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Session Duration:** ~3 hours
|
||||||
|
**Session Focus:** Implement full boat reservation system: member booking flow, admin slot/template management, configurable booking rules via Edge Function + DB, and integration tests
|
||||||
|
**Context Usage at Handoff:** High
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. **DB migration 1: Overlap constraint + cert trigger** → `supabase/migrations/20260420115333_reservations_overlap_and_cert_check.sql`
|
||||||
|
- `btree_gist` extension enabled
|
||||||
|
- `EXCLUDE USING gist (boat_id WITH =, tstzrange(start_time, end_time, '[)') WITH &&)` on `reservations`
|
||||||
|
- `enforce_reservation_cert_check()` trigger (BEFORE INSERT) — raises exception if `member.certifications` does not contain all `boat.required_certs`
|
||||||
|
- `member_has_cert_for_boat(uuid, uuid)` helper function
|
||||||
|
|
||||||
|
2. **DB migration 2: Booking rules + RBAC** → `supabase/migrations/20260420132336_booking_rules_and_rbac.sql`
|
||||||
|
- `booking_config` table (key/value, admin-editable) — RLS: authenticated read, admin write
|
||||||
|
- `holidays` table (date, name) — RLS same as above
|
||||||
|
- `is_weekend_or_holiday(date)` DB helper function
|
||||||
|
- Dropped `"Users can create own reservations"` RLS policy — direct INSERT blocked for `authenticated` role
|
||||||
|
- `reservation_slots` view (security_invoker=true) — exposes only `id, boat_id, start_time, end_time, status`
|
||||||
|
- Default config seeded: max_sessions_per_week=2, max_weekend_sessions_per_period=1, weekend_period_weeks=2, open_session_advance_hours=24
|
||||||
|
- 2026 Ontario sailing-season holidays seeded (Victoria Day through Thanksgiving)
|
||||||
|
|
||||||
|
3. **Edge Function: create-reservation** → `supabase/functions/create-reservation/index.ts`
|
||||||
|
- POST endpoint enforcing: cert check, boat availability, weekly pre-booking limit, weekend/holiday limit, open-session window bypass
|
||||||
|
- Reads all rule parameters from `booking_config` at runtime (no redeployment needed for config changes)
|
||||||
|
- Uses service_role key for insert (bypasses RLS); caller authenticated via JWT in Authorization header
|
||||||
|
- DB overlap exclusion constraint + cert trigger remain as final safety net
|
||||||
|
- Error response shape: `{ error: { code: string, message: string } }` with specific codes: `cert_required`, `slot_taken`, `booking_limit_weekly`, `booking_limit_weekend`, `boat_unavailable`
|
||||||
|
|
||||||
|
4. **Member reservation creation page** → `app/pages/reservations/create.vue`
|
||||||
|
- Step 1: 14-day horizontal date strip → available slots grouped by boat (reads `intervals` + `reservation_slots` view)
|
||||||
|
- Boats the member is not certified for shown with warning and slots disabled
|
||||||
|
- Step 2: Confirm slot → reason (select) + comment (textarea) → calls Edge Function
|
||||||
|
- Error handling maps all Edge Function error codes to user-friendly messages
|
||||||
|
|
||||||
|
5. **Admin: Manage Slots page** → `app/pages/admin/intervals.vue`
|
||||||
|
- 14-day date strip + per-boat accordion showing intervals
|
||||||
|
- Inline boat out-of-service toggle (IonToggle → updates `boats.booking_available`)
|
||||||
|
- Apply template dropdown per boat (creates intervals from `interval_templates.time_tuples`)
|
||||||
|
- Add Slot modal (boat select + time range inputs)
|
||||||
|
- Delete slot (trash button per row)
|
||||||
|
|
||||||
|
6. **Admin: Interval Templates page** → `app/pages/admin/templates.vue`
|
||||||
|
- Full CRUD: create/edit (modal with dynamic time-tuple list) / delete (IonAlert confirm)
|
||||||
|
- Time tuples displayed as chips showing HH:MM–HH:MM
|
||||||
|
|
||||||
|
7. **Admin: Booking Rules config page** → `app/pages/admin/config.vue`
|
||||||
|
- Editable numeric fields for all 4 config keys
|
||||||
|
- Holidays list with add/delete
|
||||||
|
- Save button with dirty tracking
|
||||||
|
|
||||||
|
8. **Nav wired up** → `app/app.vue`
|
||||||
|
- Boatswain+Admin: "Manage Slots" → `/admin/intervals`, "Templates" → `/admin/templates`
|
||||||
|
- Admin only: "Booking Rules" → `/admin/config`
|
||||||
|
- New icons added: `layersOutline`, `settingsOutline`
|
||||||
|
|
||||||
|
9. **Types updated** → `app/types/supabase.ts`
|
||||||
|
- Added `booking_config`, `holidays` to `Database.public.Tables`
|
||||||
|
- Added `reservation_slots` to `Database.public.Views`
|
||||||
|
|
||||||
|
10. **Integration tests** → `tests/integration/booking-constraints.test.ts`
|
||||||
|
- 6 describe blocks: overlap constraint, certification check, out-of-service, weekly limit, weekend limit, open-session window bypass, RBAC visibility
|
||||||
|
- Each creates isolated test users/boats, cleans up in afterAll
|
||||||
|
- Runs against local Supabase + Edge Functions via `yarn test:integration`
|
||||||
|
- Requires: `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_KEY` env vars; Edge Functions served with `npx supabase functions serve`
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- Edge Function not yet tested end-to-end in browser (requires `npx supabase functions serve`)
|
||||||
|
- Integration tests written but not yet run — need `supabase functions serve` running
|
||||||
|
- Adjacent-session double-booking rule (Rule 6 from guidelines) NOT YET IMPLEMENTED — marked OPEN below
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- **Option C hybrid architecture** BECAUSE: rule logic in TypeScript (maintainable, no SQL migrations for rule changes), DB constraints for integrity invariants (overlap exclusion, cert check) that must be atomic and bypass-proof — STATUS: confirmed
|
||||||
|
- **Direct INSERT locked for authenticated role** BECAUSE: members must go through Edge Function to enforce booking rules; admins retain direct access via "Admins can manage all reservations" policy — STATUS: confirmed
|
||||||
|
- **`reservation_slots` view** BECAUSE: column-level RBAC — members can see that a slot is taken (boat/time/status) without seeing user_id, reason, comment, member_ids of other members' bookings — STATUS: confirmed
|
||||||
|
- **`useSupabaseClient() as any`** in all new admin pages BECAUSE: `useSupabaseClient<Database>()` generic does not propagate through mutations in this Nuxt/@nuxtjs/supabase v1.5 setup; existing codebase uses cast pattern — STATUS: confirmed workaround
|
||||||
|
- **Open-session bookings counted separately** BECAUSE: Rule 7 says "pre-book 2 sessions per week" — bookings made within `open_session_advance_hours` of start time bypass the weekly and weekend limits; pre-booking count inferred from `(start_time - created_at) > advance_hours` — STATUS: confirmed
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- Default max_sessions_per_week: **2**
|
||||||
|
- Default max_weekend_sessions_per_period: **1**
|
||||||
|
- Default weekend_period_weeks: **2**
|
||||||
|
- Default open_session_advance_hours: **24**
|
||||||
|
- Period epoch (fixed): **2026-01-05** (Monday of ISO week 2, 2026)
|
||||||
|
- Holidays seeded: **5** (Victoria Day, Canada Day, Civic Holiday, Labour Day, Thanksgiving)
|
||||||
|
- Overlap constraint type: `tstzrange(start_time, end_time, '[)')` — half-open interval (end exclusive)
|
||||||
|
- Integration test describe blocks: **6**
|
||||||
|
- Migration files created this session: **2**
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `supabase/migrations/20260420115333_reservations_overlap_and_cert_check.sql` | Created | Overlap exclusion constraint, cert check trigger, member_has_cert_for_boat() |
|
||||||
|
| `supabase/migrations/20260420132336_booking_rules_and_rbac.sql` | Created | booking_config, holidays tables, is_weekend_or_holiday(), reservation_slots view, drop direct INSERT policy |
|
||||||
|
| `supabase/functions/create-reservation/index.ts` | Created | Edge Function — all booking rule enforcement in TypeScript |
|
||||||
|
| `app/pages/reservations/create.vue` | Created | Member reservation creation: date strip → slot picker → confirm form → Edge Function call |
|
||||||
|
| `app/pages/admin/intervals.vue` | Created | Admin interval management: date strip, per-boat slot list, service toggle, template apply |
|
||||||
|
| `app/pages/admin/templates.vue` | Created | Admin interval template CRUD |
|
||||||
|
| `app/pages/admin/config.vue` | Created | Admin booking rules config: numeric params + holidays management |
|
||||||
|
| `app/app.vue` | Modified | Added nav links for /admin/intervals, /admin/templates, /admin/config; added layersOutline, settingsOutline icons |
|
||||||
|
| `app/types/supabase.ts` | Modified | Added booking_config, holidays tables; reservation_slots view |
|
||||||
|
| `tests/integration/booking-constraints.test.ts` | Created | Integration tests for all booking constraints |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **Run integration tests**: `npx supabase functions serve` in one terminal, then `yarn test:integration` — expect all 6 describe blocks to pass; fix any failures before proceeding
|
||||||
|
2. **Implement adjacent-session rule (Rule 6)**: A member may book a second adjoining session for the same boat on the day of sail only if no other member has booked it. Add check in Edge Function: if `(start_time - now) <= open_session_advance_hours AND booking is adjacent to caller's existing booking for same boat AND no other user has the adjacent slot` → allow double-booking. Otherwise, a member booking an adjacent session is subject to normal rules.
|
||||||
|
3. **Build admin reservations view**: `/admin/reservations` — list all upcoming reservations with boat/time/member/status; ability to confirm, cancel, or modify
|
||||||
|
4. **Test the full member booking flow in browser** — run `yarn dev`, create test intervals via admin, attempt to book as member, verify Edge Function error messages surface correctly
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] **Adjacent-session double-booking (Rule 6)** — exact logic needed: does "day of sail" mean within `open_session_advance_hours`? Does the system automatically allow it if the adjacent slot is free, or does the member explicitly opt in? Impacts Edge Function logic
|
||||||
|
- [ ] **`useSupabaseClient` typing** — should we investigate properly typing the client (e.g., by configuring `supabase.types` in nuxt.config.ts to point at `types/supabase.ts`)? Currently all new pages use `as any`. Impacts type safety
|
||||||
|
- [ ] **Crew requirement (Rule 8)** — "must have at least one experienced crew member certified as helmsperson on board." Can this be enforced in the app? Members would need to name their crew at booking time. Currently not implemented. Impacts reservation form UX
|
||||||
|
- [ ] **PCOC / Basic Cruising cert codes** — what strings go in `boats.required_certs` and `members.certifications`? (e.g., `'pcoc'`, `'cya-basic-cruising'`?) Impacts cert check behavior
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: `npx supabase functions serve` is available in local dev and tests will reach `http://localhost:54321/functions/v1/create-reservation` — validate by running tests
|
||||||
|
- ASSUMED: `tstzrange(start_time, end_time, '[)')` half-open intervals match the club's intent (booking a morning slot 09:00–12:30 does not block a 12:30 afternoon slot) — validate with Patrick
|
||||||
|
- ASSUMED: ISO week (Monday–Sunday) is the correct "week" definition for the 2/week limit — validate with club guidelines
|
||||||
|
- ASSUMED: 2-week period epoch of 2026-01-05 aligns correctly with the club's alternating weekend schedule — validate with program administrator
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `supabase/functions/create-reservation/index.ts` — to debug test failures or extend with adjacent-session rule
|
||||||
|
- `tests/integration/booking-constraints.test.ts` — to run and review test results
|
||||||
|
- `app/pages/reservations/create.vue` — to test browser flow and fix any UX issues
|
||||||
|
- `app/types/supabase.ts` — if adding new tables or fixing type issues
|
||||||
@@ -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({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: {
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
|
timeline: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck",
|
||||||
|
"test:all": "yarn test && yarn test:integration && yarn test:e2e",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^4.0.2",
|
"@nuxt/test-utils": "^4.0.2",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/nuxt": "^1.1.1",
|
"@vite-pwa/nuxt": "^1.1.1",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"happy-dom": "^20.8.9",
|
"happy-dom": "^20.8.9",
|
||||||
|
|||||||
@@ -87,4 +87,4 @@ Error generating stack: `+l.message+`
|
|||||||
<div id='root'></div>
|
<div id='root'></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<template id="playwrightReportBase64">data:application/zip;base64,UEsDBBQAAAgIAGmtk1yn+5R+lgcAACNCAAAZAAAAZDc0OGFjNDAwZDA4Yjg1OTM1ZWYuanNvbu1b3W7ruBF+FZY3SQDbkUj9A9v25CCLDZDdFptsi/YkLWiJttVIoiFRmwSpb/sA+4j7JAVl5ZimJUeSZTvd1FeyaQ2Hw29+OXyBkzCiVwH0YGAbDvENTQs0Z+yYLjbpBA6K8R9ITKEHSc5no2xO/RHP4ABymvEMel9eiqdaGkPD1FzHxtQwMAkwsiaWY4jXQx4Jqtk8ItkM/PqfX0DEpmFSPMVkGvogCpOH4uuMxRQO4Dxl/6I+L9mJ2TiM6NCfpcvRiPmEhyyB3kvB9SbHUZhQ6On6APosyuMEengxgEGelu/prmsNIEkSxotfxOruB5CTafnEcu6zYnL6NKc+p4HgivAZ9L7ATzmfgUnEHuH9AKY0y6NSPOoEGScpvw0LOkhD1lAzhki71XTPwJ5ujnRs/h0KEjx9hp4mXqDzUtKl0C7ohKUUfMfYg1jY2xRtQVFiRAhhk+y4IHtJ/BmYMfbQhTJyV4TvB5BwTvxZTBNe/tB0l6zVJlkL8ZwnHHqC64dwPqcB9CYkyugAZon4zqEHwV2uafr4i6vFAJjg3+VX7MZ3ydqYJY8Bgd7XrzgerUZW0jh9/dGKSfac+NLbp2dg9fI3v5dGXpRZwdqsr496XD7pxV/AP8qvCMUS/8snTV2IvUYSAInNRxJyaSygEeX0UxR9T8Lo9AwuBqtd/zZ84nlKwR0cp+wxo+kdbLLzlrLzhlkFqWuSJ/4MlJQb0XUUusZ2RNVBY9Hqz5Xy8FnC6RNvJA+kG6qOVcnjc0oJp6Ck3IiuuU7XOZo45mRKm8kCKTwjvEUWgmwjopZCFB1CEl3F9gP5OZyK5XEG7uB5M7mZ2voSzZ6sqY5X5lTvYk91pJgaII2en4ObpRcXWwmyGXvMhDNngCRB6dXHOecsqTPJOq4jX2HLChhWGewp4+yrqUbxyfnJauyskzVefdrYZd1osZplJHFavyjKL55/ZBGVVxbG05MNNjCOB7L/AQmJJZpexXpRfPKnv92AC5am7BEQcMEIlwiDxdlZJVOcXdC/hFk4jqjiTC6L5YA7KP3lDoLVKgreByV3HqiYHyzOGugKVu2t45o9KYuxUhYbdVGWQ6P5gwFuR1Xu+mllAsx974hIjvrakms2BVfJvhW/4FjS/HLWJupujZCuuMY3osLGym5KiUYnZf/IuvchYH5Qc9PKyKwlsmva+DkK/YcelU+N5/WetM9ZaZ9pdNE+NQVW4lI5Cl8GojL8NsJQpxfHfQwcV3LiCxQcCMCtgOt2Mxt1yvod+Zn+9OP1WrRU7LYcMtV5q/LlBgmaNUIbhTTN6kkT3JUmoE6a8BGwexTg9KQ+bRQEaX371Vv6xOVl3oTTZH0jeo34ium+TtLEx2DD2EuAh7SVYhlmF8U6PubeKxz26VhaqYteGwh9G0YRuIMU0aEo9P+RPpF4HtGRz+JXrP45Ij6dsSig6enJM8vl/zSErtuqRNsYulLVzuySm6C3qnb5OA45oDEJIzBhqXTuVhcioX5qG6rcJSiqOyBrRRWtSRhFp6uR28ub239efv/p6rp/E94bXtvkjO189bLO2pe3vqFJAIRtGF4l4Ho9DHjDcW9PR0o2pYRkc6pmuQk2FeVDfRXNkVQHtOwu6vcb0JWPANXD1xNbGYveayyq8/88o/4DeGZ5unQF+w0KN2ZrouWW7qqJV0/VfmTuWIL4eBry7hH57goeqL5Sp5wTC4ye+ySKxsR/+IPPAvqNjQODYuQMiYPw0KAuGjoU20N/YkwoxpoVGKhR9cI2sZJi9eQpsbHj8TLe5inDiTQkea/fSTAtBfZTGp1Jb/JZyh4rtt6KE1r1O44v05Slq5G1FCmfkzHJKAjCACSMg5QGYSqMHWeAzOfglYn6PA73YyrEwaC84gMebWOzFsgdS2qOpuT92LJ7QqW9Y0kNW9vTp6Lj73UjvsIhE3gQHYvgVLBFEx76hNMAZJzwVVVATaxwbSW7vzLDkatauE2JspPf+CuNRIOm2IFtR117iGq2zvx2hGOPdBut60FPhWUsHbFYnWzzUYH5P4WZgycSrbRPrWMu1jH+acJp2rClWMBVDcitqha/1s2D9kh3lJN+rWOb344tnRWcOJVdjBHLmjd0CqrKYSp+d22M9wNIRSBU/k84rjyDHpyTLCt63jd65DdoP7L0gaZXSUCfoKcJiuwBejzNl/uy9cYAdU06nuia42PbpbZFbIdKNwbyZN2vEt+nWeF15ynjRVs+SFnO6bpLXl406O0agVF7jcB1nL3eIijov4kyt99LBIKipQZqfdwhEITVQ9U3NOL/dwi2e5Sj3iFoY2CRrteaws4t84KsEkodpFF8l5Z5e7PFRre3yKJRy7wgatU76Xfia+pLIQHJZmNG0qCZALGSR1o9WRED7VjcMHRFxYS1kCPQnRzam9XBLXbnZdmwv2hqf4za88SdGpu/bvSR+vWNNuc0PWQ4u5dN7BFylWtSjt1TMc/AO5ZNjoeSQ2/kEWooxt7bjd9Bx+3hGsudkW47e6m7GPLpcZfmjeOC+UPg7N12dhvqCdsOtRpnpDvq1VTUS62mgvKRajUVnFQmp+1qNYKq1aop8TdXq7lf/BdQSwMEFAAACAgAaa2TXFjMdQfVAQAAhAQAAAsAAAByZXBvcnQuanNvbs2RPW7cMBCFryJMzV1Q/xK7lG5SGQgQYwsuNbKYpUiBHMEOFmpzgBwxJwkoyfAiieHGRboZ/rx5870rjEiykyRBXEEqmqX54vwFfQCRLgwCSU/3ekQQaV1XVdHmbVHxkkE3e0naWRB5lWXHqmkZ9NpgAPFwXau7DgR0ddFIVXDe8ebclG1eYg/by88yyoKcaTiGCdWRAjAgDLRpxOpNjUNR8rapcyyKXHZ5VvVVU8TvmkxUDZORYUh+/fiZGPeo7VqN8lGrxGh7WdvBjQgMJu++oaLdzujO2uBBDX67NU7te25b/e3YaBvxpAyUM/MYiSy3fNK2rRhIax2tJ3G7EwOSj3vlZlJuHY7PEyrCLrqSNIB4gE8zDUlv3BPElxcQ5Gdk4DHMZgcliaQaRrS0C96kBhnPqgMvDhm/56kocpGWxzQvvwKDpzXpO9vhMwi+nBb2HnRsSzz3KW9UXrdYV7Ju8Ab6bCMatKSVJOwSqRSGkJBLJu9o3SzxbiZMPHbao6L1csvqw5Io3kyibZr/KYj6mLb/DmL7GlWuQI6kAZGxV1Oxme1ryxn0Rl6+r1W46GnaT19sLlHxhm609wffDx/JAL13/gXttBO/LgxGqQZtVxen5TdQSwECPwMUAAAICABprZNcp/uUfpYHAAAjQgAAGQAAAAAAAAAAAAAAtIEAAAAAZDc0OGFjNDAwZDA4Yjg1OTM1ZWYuanNvblBLAQI/AxQAAAgIAGmtk1xYzHUH1QEAAIQEAAALAAAAAAAAAAAAAAC0gc0HAAByZXBvcnQuanNvblBLBQYAAAAAAgACAIAAAADLCQAAAAA=</template>
|
<template id="playwrightReportBase64">data:application/zip;base64,UEsDBBQAAAgIAEo6llwA02xf0gcAAOhCAAAZAAAAZDc0OGFjNDAwZDA4Yjg1OTM1ZWYuanNvbu1b3W7jNhZ+FZY3SQBbEfUvAe3uZJCiAdJuMckUaCdpQUu0rVoSDYmaJMj6tg/QR+yTFJSVEU3LsSQrdtDUV7JpHR6S5zv/fITjMCIXAfRgYBsO9g1VDVRn5JiubpIxHBTjP+CYQA/inE2VbE58hWVwABnJWAa9T4/F00YaQ8NUXcfWiWHoONA1a2w5Bn89ZBGnms0jnE3BX3/8CSI6CZPiKcaT0AdRmMyKr1MaEziA85T+TnxWshPTURiRoT9Nl6MR9TELaQK9x4LrdY6jMCHQQ2gAfRrlcQI9fTGAQZ6W75kmsgcQJwllxS98dbcDyPCkfKI582kxObmfE5+RgHOF2RR6n+C7nE3BOKJ38HYAU5LlUbk98gQZwym7Dgs6mqpZQ9UYato1Qh5yPKQrqmn8AjkJlj5AT+UvkHm50+WmnZExTQn4jtIZX9hWipbLKVaM6KZeR3ZUkD3H/hRMKZ01oWyrq5RdpyJ8O4CYMexPY5Kw8oemp2RVh2Qt+HOeMOihAcxm4XxOAuiNcZSRAcwS/p1BD4KbXFXR6JOrxgCY4P/lV92Nb5KVMUscA1x6n77qsVKNVLtx/PSjFePsIfGFt49PQPXy198II4/SrGBl1qdHFJdPqPgL+LX8qmmxwP/ySZUXYq+QBEBg8w6HTBgLSEQYeRdF3+MwOj6Bi0F16t+G9yxPCbiBo5TeZSS9gQ1OHtlo9eSRiepk6hLniT8FJelGhC2JsGE+L1ObhGPR6s+1O+LThJF71mhHdM1YZVzT6jbkfUowI6Ck3IiuLW2IfbD9mOMJabYZhsS0bT6zGZxsE6KmJHM22sdOdN22H/DncMKXxyi4gaeN9s3QJFWNLLcfjYr0SqWiLjoVaZK6AcLo6Sm4WlpyfpYgm9K7jBt0CnASlJZ9lDNGk01qGembyNfos0IO65T2hDL6RV1r8dHpUTV20kkjV582uhkZLVaz9CaONy+KsLOHDzQi4srCeHK0xoauxwPRBoEExwJNr2a9Wnz0v5+vwBlNU3oHMDijmAmEweLkpJYpRs/IT2EWjiIiGZTzYjngBgp/uYGgWkXB+6DkzgM184PFSQOwWKoMFtfsyf9ARoUWW+uCln2L8xuTuB2x3PXTSgeYL30iPELq60gu6QRcJC+N/IJjAfrlrE3wbirmms+p9YR2Uwg3OqH9LYPvTcj5XvVNKy2zEs6uwPF9FPqz/tDnOFKsr/aEPqdCn2l0QZ8cCEueqeiIL11RUfzWHFGnF8t9CDmu5cTnUrAnAW4luG43tbEJrN/hz+Tjh8sVd6k4bdFn2mSuypcbxGimYjnaKhCMvsyQWwFB6wSEtyC6B5GbntDTBh+a2rdZvSb3TFzmVThJVg+iV4+vmO7LJE1MjL2W/diS6mqKLE2tkGWYXZB1eKF7rfLwkoalFV7QRkfo2zCKwA0kGhnydP9/yT2O5xFRfBo/CeuPEfbJlEYBSY+PHmgu/qeZ7BqS7GpWT7IrJO7MLsGJti1xl4/ikAES4zACY5oK5bdNPpLWT3ZD3nhBFuUjEGFRR2scRtFxNXJ9fnX92/n37y4u+1fivQlsm6CxnbVeplr7stdXJAkAVw7DiwRcrjoCW0z38/FIyaYQkaxP1Sw4sW0pODHUvuAnZAItuwv8/gFYeQuiuv+MYitl0XuSRbb+76fEn4EHmqdLU/CybuHabNtRbinIkaqKur2lNtwY5eaOOYi3h5BXL5GvLuOhbU7VSbViLqOnPo6iEfZn//FpQL4mroqQid2hO/LdoeG7wXBkjfWh7Y5HKAiIa2qoQfrCUizDkDs3ejKVurFjiVl/zlSGY2FIMF9fCXJa7tjHNDoR3mTTlN7VnL0VJ6Tudz0+T1OaViMrQVI+xyOcERCEAUgoAykJwpRrO0YBns/BExObIzm9H13Ba4PiivdY3tbNjZLcKalmKY6qybVcvafgn9PZKa2mW88HUEXr39NJfJGHjAsEb10Ex5wtkrDQx4wEIGOYVYkBObTSNyaz+8s0HDizpbdJU3YqGE0JDsKkt4rZx7lP4zCZgA8kI+nnZXfoS5dKn9YgREf1fDSJkBzFNmSA9VS+0YXyjaN2gddBJf6NCuOB+ic6qQs597pYhdK7MSNpw2ZoR7FNyf3R3brOxNY9j5yyFJ1sAdhLtaLWcCLm0cV8DM2ad6I6im3pEtnX1395O4CEO2/l/7itzTPowTnOsqJff62/f432HU1nJL1IAnIPPZVTpDPosTRfnsyztx2Ia5LRGKmOr9susS1sO0S47ZAnq64A9n2SFY7CPKWsuFIAUpozsupFLC9J9HYFwth4BUJDrvuiVyCWE2wVNNfq9QqEoziqKouuU0e27RUITllqSjL0fuzqv1cgDnAFoo2WdeQed940unPDP6crV0H3omZ3afh3FMeS3EsxqdCt4Z8TNSWiW7D1qhr+A5xNRxSnQaMNdDXZPzd6Ssvw9pSd0jIGklDGFYbo4e5k1rb6xc+onsfldYNFUxVkbCyF7tSV/eWkD3TbwGhTYuohgto94eMq6lozr9tXRGroOyZ8Dicm+z7JA2R/jBfvlX4F7cJ7a4vXVMV1JCuJeroEY4il7y6dJ4eV5jchaK+2L92Qy4PdszYaUlRVvlzbS9amoCyj5yBZm4IT+eZzbYjaKmvDySKJ7Bbt8I9L2twu/gZQSwMEFAAACAgASjqWXE7DnRjXAQAAhQQAAAsAAAByZXBvcnQuanNvbs2RPW7cMBCFryJMzV2I+he7lG5SGQgQYwsuNbKYpUiBHMEOFmpzgBwxJwkoyfAiieHGRboZ/rx5870rjEiykyRBXEEqmqX54vwFfQDBFwaBpKd7PSIIXtdVU1ZVy6syZ9DNXpJ2FkRbVOWx4C2DXhsMIB6ua3XXgYCuLhqpijTt0ubclG1eYg/by88yyoKcaTiGCdWRAjAgDLRpxOpNjUNRpm1T51gUuezyrOqrpojfNZmoGiYjw5D8+vEzMe5R27Ua5aNWidH2sraDGxEYTN59Q0W7ndGdtcGDGvx2a5za99y2+tux0Tbi4QyUM/NoQeTLLZ+y5DUDaa2j9SRud2JA8nGv3EzKrcPxeUJF2EVXkgYQD/BppiHpjXuC+PICgvyMDDyG2eygJJFUw4iWdsGb1CBLs+qQFocsu+dc8Ebw/JiWxVdg8LQmfWc7fAaRLqeFvQcd2xLPPU8bldct1pWsG7yBPtuIBi1pJQm7RCqFISTkksk7WjdLvJsJE4+d9qhovdyy+rAkijeTyHjb/k9JNMe6rf6ZxPY1qlyBHEkDImOvpmIz29c2ZdAbefm+VuGip2k/fbG5RMUbvNHeH4A/fCQD9N75F7TTTvy6MBilGrRdXZyW31BLAQI/AxQAAAgIAEo6llwA02xf0gcAAOhCAAAZAAAAAAAAAAAAAAC0gQAAAABkNzQ4YWM0MDBkMDhiODU5MzVlZi5qc29uUEsBAj8DFAAACAgASjqWXE7DnRjXAQAAhQQAAAsAAAAAAAAAAAAAALSBCQgAAHJlcG9ydC5qc29uUEsFBgAAAAACAAIAgAAAAAkKAAAAAA==</template>
|
||||||
@@ -394,3 +394,9 @@ s3_secret_key = "env(S3_SECRET_KEY)"
|
|||||||
# declarative_schema_path = "./declarative"
|
# declarative_schema_path = "./declarative"
|
||||||
# JSON string passed through to pg-delta SQL formatting.
|
# JSON string passed through to pg-delta SQL formatting.
|
||||||
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
||||||
|
|
||||||
|
[functions.create-reservation]
|
||||||
|
enabled = true
|
||||||
|
verify_jwt = false
|
||||||
|
import_map = "./functions/create-reservation/deno.json"
|
||||||
|
|
||||||
|
|||||||
3
supabase/functions/create-reservation/.npmrc
Normal file
3
supabase/functions/create-reservation/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Configuration for private npm package dependencies
|
||||||
|
# For more information on using private registries with Edge Functions, see:
|
||||||
|
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
|
||||||
5
supabase/functions/create-reservation/deno.json
Normal file
5
supabase/functions/create-reservation/deno.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
282
supabase/functions/create-reservation/index.ts
Normal file
282
supabase/functions/create-reservation/index.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { createClient } from "npm:@supabase/supabase-js@2"
|
||||||
|
|
||||||
|
interface CreateReservationBody {
|
||||||
|
boat_id: string
|
||||||
|
start_time: string // ISO 8601
|
||||||
|
end_time: string // ISO 8601
|
||||||
|
reason?: string
|
||||||
|
comment?: string
|
||||||
|
member_ids?: string[]
|
||||||
|
guest_ids?: string[]
|
||||||
|
target_user_id?: string // admin only: create on behalf of another member
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingConfig {
|
||||||
|
max_sessions_per_week: number
|
||||||
|
max_weekend_sessions_per_period: number
|
||||||
|
weekend_period_weeks: number
|
||||||
|
open_session_advance_hours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReservationRow {
|
||||||
|
boat_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns ISO date string for the Monday of the ISO week containing `d`.
|
||||||
|
function isoWeekStart(d: Date): Date {
|
||||||
|
const day = d.getUTCDay() || 7 // treat Sunday as 7
|
||||||
|
const monday = new Date(d)
|
||||||
|
monday.setUTCDate(d.getUTCDate() - (day - 1))
|
||||||
|
monday.setUTCHours(0, 0, 0, 0)
|
||||||
|
return monday
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed epoch: first Monday of 2026 (2026-01-05)
|
||||||
|
const PERIOD_EPOCH = new Date('2026-01-05T00:00:00Z')
|
||||||
|
|
||||||
|
function weeksSince(d: Date, epoch: Date): number {
|
||||||
|
return Math.floor((isoWeekStart(d).getTime() - epoch.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekendOrHoliday(date: Date, holidays: Set<string>): boolean {
|
||||||
|
const dow = date.getUTCDay() // 0=Sun, 6=Sat
|
||||||
|
const iso = date.toISOString().slice(0, 10)
|
||||||
|
return dow === 0 || dow === 6 || holidays.has(iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorResponse(code: string, message: string, status = 422): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: { code, message } }),
|
||||||
|
{ status, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return errorResponse('method_not_allowed', 'POST only', 405)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate the caller via local JWT verification (no network round-trip)
|
||||||
|
const authHeader = req.headers.get('Authorization')
|
||||||
|
if (!authHeader) return errorResponse('unauthorized', 'Missing Authorization header', 401)
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
|
// Service-role client for reads + insert (bypasses RLS)
|
||||||
|
const adminClient = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||||
|
{ auth: { autoRefreshToken: false, persistSession: false } },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: { user }, error: authError } = await adminClient.auth.getUser(token)
|
||||||
|
if (authError || !user) return errorResponse('unauthorized', 'Invalid session', 401)
|
||||||
|
|
||||||
|
const callerId = user.id
|
||||||
|
|
||||||
|
// Parse body
|
||||||
|
let body: CreateReservationBody
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return errorResponse('invalid_body', 'Request body must be JSON', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(start_time)
|
||||||
|
const endDate = new Date(end_time)
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return errorResponse('invalid_dates', 'start_time and end_time must be valid ISO 8601 timestamps', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
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')
|
||||||
|
.select('key, value')
|
||||||
|
|
||||||
|
if (configErr) return errorResponse('config_error', 'Failed to load booking config', 500)
|
||||||
|
|
||||||
|
const config: BookingConfig = {
|
||||||
|
max_sessions_per_week: 2,
|
||||||
|
max_weekend_sessions_per_period: 1,
|
||||||
|
weekend_period_weeks: 2,
|
||||||
|
open_session_advance_hours: 24,
|
||||||
|
}
|
||||||
|
for (const row of configRows ?? []) {
|
||||||
|
const k = row.key as keyof BookingConfig
|
||||||
|
if (k in config) (config as Record<string, number>)[k] = Number(row.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Load holidays ────────────────────────────────────────────────────────
|
||||||
|
const { data: holidayRows } = await adminClient.from('holidays').select('date')
|
||||||
|
const holidays = new Set<string>((holidayRows ?? []).map((h: { date: string }) => h.date))
|
||||||
|
|
||||||
|
// ── 3. Certification check ─────────────────────────────────────────────────
|
||||||
|
const { data: boat, error: boatErr } = await adminClient
|
||||||
|
.from('boats')
|
||||||
|
.select('required_certs, booking_available')
|
||||||
|
.eq('id', boat_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
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 (!isAdmin && !isPast && boat.required_certs.length > 0) {
|
||||||
|
const { data: member } = await adminClient
|
||||||
|
.from('members')
|
||||||
|
.select('certifications')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const memberCerts: string[] = member?.certifications ?? []
|
||||||
|
const missing = boat.required_certs.filter((c: string) => !memberCerts.includes(c))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return errorResponse(
|
||||||
|
'cert_required',
|
||||||
|
`You are not certified for this boat. Missing: ${missing.join(', ')}`,
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Open-session window check ───────────────────────────────────────────
|
||||||
|
const advanceMs = config.open_session_advance_hours * 60 * 60 * 1000
|
||||||
|
const isOpenSession = startDate.getTime() - Date.now() <= advanceMs
|
||||||
|
|
||||||
|
if (!isAdmin && !isPast && !isOpenSession) {
|
||||||
|
// ── 5. Weekly pre-booking limit ─────────────────────────────────────────
|
||||||
|
const weekStart = isoWeekStart(startDate)
|
||||||
|
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const { data: weekReservations } = await adminClient
|
||||||
|
.from('reservations')
|
||||||
|
.select('start_time, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.neq('status', 'cancelled')
|
||||||
|
.gte('start_time', weekStart.toISOString())
|
||||||
|
.lt('start_time', weekEnd.toISOString())
|
||||||
|
|
||||||
|
const preBookingsThisWeek = (weekReservations as ReservationRow[] ?? []).filter(r => {
|
||||||
|
const rStart = new Date(r.start_time).getTime()
|
||||||
|
const rCreated = new Date(r.created_at).getTime()
|
||||||
|
return (rStart - rCreated) > advanceMs
|
||||||
|
})
|
||||||
|
|
||||||
|
if (preBookingsThisWeek.length >= config.max_sessions_per_week) {
|
||||||
|
return errorResponse(
|
||||||
|
'booking_limit_weekly',
|
||||||
|
`You have reached the maximum of ${config.max_sessions_per_week} pre-booked sessions for this week.`,
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Weekend / holiday session limit ──────────────────────────────────
|
||||||
|
if (isWeekendOrHoliday(startDate, holidays)) {
|
||||||
|
const w = weeksSince(startDate, PERIOD_EPOCH)
|
||||||
|
const periodIndex = Math.floor(w / config.weekend_period_weeks)
|
||||||
|
const periodStart = new Date(
|
||||||
|
PERIOD_EPOCH.getTime() + periodIndex * config.weekend_period_weeks * 7 * 24 * 60 * 60 * 1000,
|
||||||
|
)
|
||||||
|
const periodEnd = new Date(
|
||||||
|
periodStart.getTime() + config.weekend_period_weeks * 7 * 24 * 60 * 60 * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: periodReservations } = await adminClient
|
||||||
|
.from('reservations')
|
||||||
|
.select('start_time, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.neq('status', 'cancelled')
|
||||||
|
.gte('start_time', periodStart.toISOString())
|
||||||
|
.lt('start_time', periodEnd.toISOString())
|
||||||
|
|
||||||
|
const weekendPreBookings = (periodReservations as ReservationRow[] ?? []).filter(r => {
|
||||||
|
const rStart = new Date(r.start_time)
|
||||||
|
const rCreated = new Date(r.created_at).getTime()
|
||||||
|
return (
|
||||||
|
isWeekendOrHoliday(rStart, holidays) &&
|
||||||
|
(rStart.getTime() - rCreated) > advanceMs
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (weekendPreBookings.length >= config.max_weekend_sessions_per_period) {
|
||||||
|
return errorResponse(
|
||||||
|
'booking_limit_weekend',
|
||||||
|
`You have reached the maximum of ${config.max_weekend_sessions_per_period} weekend session(s) per ${config.weekend_period_weeks}-week period.`,
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Insert — DB overlap constraint and cert trigger are the final safety net
|
||||||
|
const { data: reservation, error: insertErr } = await adminClient
|
||||||
|
.from('reservations')
|
||||||
|
.insert({
|
||||||
|
boat_id,
|
||||||
|
user_id: userId,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
reason,
|
||||||
|
comment,
|
||||||
|
member_ids,
|
||||||
|
guest_ids,
|
||||||
|
status: 'confirmed',
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (insertErr) {
|
||||||
|
if (insertErr.message.includes('no_overlapping_reservations') || insertErr.message.includes('overlapping')) {
|
||||||
|
return errorResponse('slot_taken', 'This slot was just booked by someone else. Please choose another.', 409)
|
||||||
|
}
|
||||||
|
if (insertErr.message.includes('certifications')) {
|
||||||
|
return errorResponse('cert_required', 'You are not certified for this boat.', 422)
|
||||||
|
}
|
||||||
|
return errorResponse('insert_failed', insertErr.message, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ reservation }),
|
||||||
|
{ status: 201, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } },
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Overlap prevention for reservations using btree_gist exclusion constraint
|
||||||
|
create extension if not exists btree_gist;
|
||||||
|
|
||||||
|
alter table public.reservations
|
||||||
|
add constraint no_overlapping_reservations
|
||||||
|
exclude using gist (
|
||||||
|
boat_id with =,
|
||||||
|
tstzrange(start_time, end_time, '[)') with &&
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Function: check member has required certs for a boat
|
||||||
|
create or replace function public.member_has_cert_for_boat(p_user_id uuid, p_boat_id uuid)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when array_length(b.required_certs, 1) is null or array_length(b.required_certs, 1) = 0
|
||||||
|
then true
|
||||||
|
else
|
||||||
|
coalesce(
|
||||||
|
(select m.certifications @> b.required_certs
|
||||||
|
from public.members m
|
||||||
|
where m.user_id = p_user_id),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
from public.boats b
|
||||||
|
where b.id = p_boat_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Trigger: enforce cert check on reservation insert
|
||||||
|
create or replace function public.enforce_reservation_cert_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not coalesce(public.member_has_cert_for_boat(new.user_id, new.boat_id), false) then
|
||||||
|
raise exception 'Member does not have required certifications for this boat';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger check_reservation_certs
|
||||||
|
before insert on public.reservations
|
||||||
|
for each row execute function public.enforce_reservation_cert_check();
|
||||||
|
|
||||||
|
grant execute on function public.member_has_cert_for_boat(uuid, uuid) to authenticated;
|
||||||
104
supabase/migrations/20260420132336_booking_rules_and_rbac.sql
Normal file
104
supabase/migrations/20260420132336_booking_rules_and_rbac.sql
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- BOOKING CONFIG: configurable rule parameters (read by Edge Function)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create table public.booking_config (
|
||||||
|
key text primary key,
|
||||||
|
value jsonb not null,
|
||||||
|
description text
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.booking_config enable row level security;
|
||||||
|
|
||||||
|
create policy "Authenticated users can read booking config" on public.booking_config
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
create policy "Admins can manage booking config" on public.booking_config
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.booking_config (key, value, description) values
|
||||||
|
('max_sessions_per_week', '2', 'Max pre-booked sessions per member per ISO week (Mon–Sun)'),
|
||||||
|
('max_weekend_sessions_per_period', '1', 'Max weekend/holiday sessions per alternating period'),
|
||||||
|
('weekend_period_weeks', '2', 'Number of weeks in the alternating weekend period'),
|
||||||
|
('open_session_advance_hours', '24', 'Hours before session start where pre-booking limits are waived');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- HOLIDAYS: configurable statutory holiday list
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create table public.holidays (
|
||||||
|
date date primary key,
|
||||||
|
name text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.holidays enable row level security;
|
||||||
|
|
||||||
|
create policy "Authenticated users can read holidays" on public.holidays
|
||||||
|
for select using (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
create policy "Admins can manage holidays" on public.holidays
|
||||||
|
for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ontario statutory + civic holidays for 2026 sailing season
|
||||||
|
insert into public.holidays (date, name) values
|
||||||
|
('2026-05-18', 'Victoria Day'),
|
||||||
|
('2026-07-01', 'Canada Day'),
|
||||||
|
('2026-08-03', 'Civic Holiday'),
|
||||||
|
('2026-09-07', 'Labour Day'),
|
||||||
|
('2026-10-12', 'Thanksgiving');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- HELPER: is_weekend_or_holiday (used by Edge Function via RPC)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create or replace function public.is_weekend_or_holiday(p_date date)
|
||||||
|
returns boolean
|
||||||
|
language sql stable security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
extract(dow from p_date) in (0, 6)
|
||||||
|
or exists (select 1 from public.holidays where date = p_date);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.is_weekend_or_holiday(date) to authenticated;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- LOCK DIRECT INSERT: revoke INSERT on reservations from authenticated
|
||||||
|
--
|
||||||
|
-- Members must create reservations through the create-reservation Edge Function.
|
||||||
|
-- The Edge Function uses the service_role key (bypasses RLS).
|
||||||
|
-- Admins retain direct INSERT/UPDATE/DELETE via their existing "all" policy.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
drop policy if exists "Users can create own reservations" on public.reservations;
|
||||||
|
|
||||||
|
-- Admins can still manage directly; members go through the Edge Function.
|
||||||
|
-- The overlap exclusion constraint and cert check trigger remain as DB-level safety nets.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- RBAC VIEW: reservation_slots
|
||||||
|
--
|
||||||
|
-- Exposes only boat_id, start_time, end_time, status to all authenticated users.
|
||||||
|
-- Hides user_id, reason, comment, member_ids, guest_ids.
|
||||||
|
-- Members use this view to check slot availability.
|
||||||
|
-- Admins query the reservations table directly for full management.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create view public.reservation_slots
|
||||||
|
with (security_invoker = false)
|
||||||
|
as
|
||||||
|
select id, boat_id, start_time, end_time, status
|
||||||
|
from public.reservations;
|
||||||
|
|
||||||
|
grant select on public.reservation_slots to authenticated;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- Fix infinite recursion in members RLS.
|
||||||
|
--
|
||||||
|
-- The original "Admins can read all members" and "Admins can manage all members"
|
||||||
|
-- policies check role by querying members itself, which causes infinite recursion
|
||||||
|
-- when any authenticated user accesses the members table.
|
||||||
|
--
|
||||||
|
-- Solution: SECURITY DEFINER helper functions that bypass RLS to check the caller's
|
||||||
|
-- role, then reference those functions from the policies.
|
||||||
|
|
||||||
|
create or replace function public.current_user_role()
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
stable
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select role from public.members where user_id = auth.uid() limit 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.current_user_has_role(roles text[])
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
stable
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role = any(roles)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Drop the recursive policies on members
|
||||||
|
drop policy if exists "Admins can read all members" on public.members;
|
||||||
|
drop policy if exists "Admins can manage all members" on public.members;
|
||||||
|
|
||||||
|
-- Replace with non-recursive equivalents using the SECURITY DEFINER helper
|
||||||
|
create policy "Admins can read all members" on public.members
|
||||||
|
for select using (public.current_user_has_role(array['admin', 'boatswain', 'instructor']));
|
||||||
|
|
||||||
|
create policy "Admins can manage all members" on public.members
|
||||||
|
for all using (public.current_user_has_role(array['admin']));
|
||||||
|
|
||||||
|
-- Also fix all other tables that query members inline (same recursion risk)
|
||||||
|
drop policy if exists "Admins can manage boats" on public.boats;
|
||||||
|
create policy "Admins can manage boats" on public.boats
|
||||||
|
for all using (public.current_user_has_role(array['admin', 'boatswain']));
|
||||||
|
|
||||||
|
drop policy if exists "Admins can manage interval templates" on public.interval_templates;
|
||||||
|
create policy "Admins can manage interval templates" on public.interval_templates
|
||||||
|
for all using (public.current_user_has_role(array['admin', 'boatswain']));
|
||||||
|
|
||||||
|
drop policy if exists "Admins can manage intervals" on public.intervals;
|
||||||
|
create policy "Admins can manage intervals" on public.intervals
|
||||||
|
for all using (public.current_user_has_role(array['admin', 'boatswain']));
|
||||||
|
|
||||||
|
drop policy if exists "Admins can read all reservations" on public.reservations;
|
||||||
|
create policy "Admins can read all reservations" on public.reservations
|
||||||
|
for select using (public.current_user_has_role(array['admin', 'boatswain']));
|
||||||
|
|
||||||
|
drop policy if exists "Admins can manage all reservations" on public.reservations;
|
||||||
|
create policy "Admins can manage all reservations" on public.reservations
|
||||||
|
for all using (public.current_user_has_role(array['admin', 'boatswain']));
|
||||||
|
|
||||||
|
drop policy if exists "Admins can manage reference docs" on public.reference_docs;
|
||||||
|
create policy "Admins can manage reference docs" on public.reference_docs
|
||||||
|
for all using (public.current_user_has_role(array['admin']));
|
||||||
|
|
||||||
|
-- booking_config and holidays policies (added in later migration, same pattern)
|
||||||
|
drop policy if exists "Admins can manage booking config" on public.booking_config;
|
||||||
|
create policy "Admins can manage booking config" on public.booking_config
|
||||||
|
for all using (public.current_user_has_role(array['admin']));
|
||||||
|
|
||||||
|
drop policy if exists "Admins can manage holidays" on public.holidays;
|
||||||
|
create policy "Admins can manage holidays" on public.holidays
|
||||||
|
for all using (public.current_user_has_role(array['admin']));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Drop the overly-broad SELECT policy that allowed any authenticated user to read
|
||||||
|
-- all reservations. Non-owner visibility is now handled by the reservation_slots
|
||||||
|
-- view (security_invoker, exposes only id/boat_id/start_time/end_time/status).
|
||||||
|
drop policy if exists "Authenticated users can read non-private reservation slots" on public.reservations;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- The reservation_slots view was created with security_invoker=true, which means
|
||||||
|
-- it evaluates RLS as the calling user. After removing the broad select policy,
|
||||||
|
-- other members see 0 rows. Switch to security_definer so the view runs as the
|
||||||
|
-- owner (bypassing RLS), while still exposing only the safe columns.
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
grant select on public.reservation_slots to authenticated;
|
||||||
44
supabase/migrations/20260420200000_boat_images_storage.sql
Normal file
44
supabase/migrations/20260420200000_boat_images_storage.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Create boat-images storage bucket
|
||||||
|
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'boat-images',
|
||||||
|
'boat-images',
|
||||||
|
true,
|
||||||
|
10485760,
|
||||||
|
array['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
-- Public read (bucket is public, but explicit policy is required for RLS)
|
||||||
|
create policy "Anyone can read boat images" on storage.objects
|
||||||
|
for select using (bucket_id = 'boat-images');
|
||||||
|
|
||||||
|
-- Admins/boatswains can upload
|
||||||
|
create policy "Admins can upload boat images" on storage.objects
|
||||||
|
for insert with check (
|
||||||
|
bucket_id = 'boat-images' and
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins/boatswains can replace/update
|
||||||
|
create policy "Admins can update boat images" on storage.objects
|
||||||
|
for update using (
|
||||||
|
bucket_id = 'boat-images' and
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins/boatswains can delete
|
||||||
|
create policy "Admins can delete boat images" on storage.objects
|
||||||
|
for delete using (
|
||||||
|
bucket_id = 'boat-images' and
|
||||||
|
exists (
|
||||||
|
select 1 from public.members
|
||||||
|
where user_id = auth.uid() and role in ('admin', 'boatswain')
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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)
|
// Auth callback redirects to home (authenticated state)
|
||||||
await expect(page).toHaveURL('/')
|
await expect(page).toHaveURL('/')
|
||||||
await expect(page.getByText('Welcome to OYS Borrow a Boat')).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Upcoming Reservations' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unauthenticated access to protected route redirects to splash', async ({ page }) => {
|
test('unauthenticated access to protected route redirects to splash', async ({ page }) => {
|
||||||
|
|||||||
@@ -86,8 +86,7 @@ describe('magic link login — session creation', () => {
|
|||||||
// and /auth/callback calls supabase.auth.verifyOtp (hash-based) or
|
// and /auth/callback calls supabase.auth.verifyOtp (hash-based) or
|
||||||
// supabase.auth.exchangeCodeForSession (PKCE)
|
// supabase.auth.exchangeCodeForSession (PKCE)
|
||||||
const { data: sessionData, error: sessionError } = await anonClient.auth.verifyOtp({
|
const { data: sessionData, error: sessionError } = await anonClient.auth.verifyOtp({
|
||||||
email: TEST_EMAIL,
|
token_hash: token!,
|
||||||
token: token!,
|
|
||||||
type: 'magiclink',
|
type: 'magiclink',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,8 +103,7 @@ describe('magic link login — session creation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { data: sessionData } = await anonClient.auth.verifyOtp({
|
const { data: sessionData } = await anonClient.auth.verifyOtp({
|
||||||
email: TEST_EMAIL,
|
token_hash: linkData.properties!.hashed_token!,
|
||||||
token: linkData.properties!.hashed_token!,
|
|
||||||
type: 'magiclink',
|
type: 'magiclink',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
943
tests/integration/booking-constraints.test.ts
Normal file
943
tests/integration/booking-constraints.test.ts
Normal file
@@ -0,0 +1,943 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for booking constraints enforced by the create-reservation Edge Function
|
||||||
|
* and the database (overlap exclusion constraint, cert check trigger).
|
||||||
|
*
|
||||||
|
* Requires local Supabase running with Edge Functions served:
|
||||||
|
* npx supabase start
|
||||||
|
* npx supabase functions serve
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* yarn test:integration
|
||||||
|
*
|
||||||
|
* Each describe block creates isolated test users and cleans up after itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL ?? 'http://localhost:54321'
|
||||||
|
const SUPABASE_ANON_KEY = process.env.SUPABASE_KEY ?? ''
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? ''
|
||||||
|
const FUNCTIONS_URL = `${SUPABASE_URL}/functions/v1`
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let adminClient: SupabaseClient
|
||||||
|
|
||||||
|
function makeAnonClient() {
|
||||||
|
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestUser(email: string, certifications: string[] = [], role = 'member') {
|
||||||
|
const { data, error } = await adminClient.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
email_confirm: true,
|
||||||
|
})
|
||||||
|
if (error || !data.user) throw new Error(`Failed to create user: ${error?.message}`)
|
||||||
|
const userId = data.user.id
|
||||||
|
|
||||||
|
await adminClient.from('members').upsert({
|
||||||
|
user_id: userId,
|
||||||
|
email,
|
||||||
|
certifications,
|
||||||
|
role,
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
}, { onConflict: 'user_id' })
|
||||||
|
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionToken(email: string): Promise<string> {
|
||||||
|
const anon = makeAnonClient()
|
||||||
|
const { data: linkData } = await adminClient.auth.admin.generateLink({
|
||||||
|
type: 'magiclink',
|
||||||
|
email,
|
||||||
|
})
|
||||||
|
const { data: sessionData } = await anon.auth.verifyOtp({
|
||||||
|
token_hash: linkData.properties!.hashed_token!,
|
||||||
|
type: 'magiclink',
|
||||||
|
})
|
||||||
|
return sessionData.session!.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callCreateReservation(
|
||||||
|
token: string,
|
||||||
|
body: {
|
||||||
|
boat_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
reason?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${FUNCTIONS_URL}/create-reservation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'apikey': SUPABASE_ANON_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reason: 'Open Sail', ...body }),
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
return { status: res.status, body: json }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestBoat(requiredCerts: string[] = [], available = true): Promise<string> {
|
||||||
|
const { data, error } = await adminClient.from('boats').insert({
|
||||||
|
name: `Test Boat ${Date.now()}`,
|
||||||
|
required_certs: requiredCerts,
|
||||||
|
booking_available: available,
|
||||||
|
max_passengers: 6,
|
||||||
|
}).select('id').single()
|
||||||
|
if (error) throw new Error(`Failed to create boat: ${error.message}`)
|
||||||
|
return data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function directInsertReservation(boatId: string, userId: string, start: string, end: string) {
|
||||||
|
const { error } = await adminClient.from('reservations').insert({
|
||||||
|
boat_id: boatId,
|
||||||
|
user_id: userId,
|
||||||
|
start_time: start,
|
||||||
|
end_time: end,
|
||||||
|
reason: 'Test',
|
||||||
|
status: 'confirmed',
|
||||||
|
})
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future Monday 09:00–12:30 (within current ISO week, far enough ahead)
|
||||||
|
function futureSlot(daysAhead: number, hour = 9, endHour = 12, endMin = 30): { start_time: string; end_time: string } {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCDate(d.getUTCDate() + daysAhead)
|
||||||
|
d.setUTCHours(hour, 0, 0, 0)
|
||||||
|
const e = new Date(d)
|
||||||
|
e.setUTCHours(endHour, endMin, 0, 0)
|
||||||
|
return { start_time: d.toISOString(), end_time: e.toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test Suite ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!SUPABASE_SERVICE_ROLE_KEY) {
|
||||||
|
throw new Error('SUPABASE_SERVICE_ROLE_KEY is required. Run: npx supabase status')
|
||||||
|
}
|
||||||
|
adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Overlap constraint (DB-level) ─────────────────────────────────────────────
|
||||||
|
describe('overlap constraint', () => {
|
||||||
|
const email = `test-overlap-${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('allows first booking for a slot', async () => {
|
||||||
|
const slot = futureSlot(14)
|
||||||
|
const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an overlapping booking for the same boat', async () => {
|
||||||
|
// Use a different week from the first test so the weekly pre-booking limit isn't reached
|
||||||
|
const slot = futureSlot(21)
|
||||||
|
// Book once directly via admin
|
||||||
|
await directInsertReservation(boatId, userId, slot.start_time, slot.end_time)
|
||||||
|
// Second booking for same slot via Edge Function — DB exclusion constraint fires
|
||||||
|
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(409)
|
||||||
|
expect(body.error.code).toBe('slot_taken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a booking on the same boat at a different time', async () => {
|
||||||
|
// Third distinct week — no pre-booking limit conflict, different time slot
|
||||||
|
const slot = futureSlot(28, 13, 16, 30)
|
||||||
|
const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Certification check (DB trigger + Edge Function) ─────────────────────────
|
||||||
|
describe('certification check', () => {
|
||||||
|
const emailNoCert = `test-nocert-${Date.now()}@oysqn.test`
|
||||||
|
const emailCert = `test-cert-${Date.now()}@oysqn.test`
|
||||||
|
let userNoCert: string
|
||||||
|
let userCert: string
|
||||||
|
let boatId: string
|
||||||
|
let tokenNoCert: string
|
||||||
|
let tokenCert: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
boatId = await createTestBoat(['j27'])
|
||||||
|
userNoCert = await createTestUser(emailNoCert, [])
|
||||||
|
userCert = await createTestUser(emailCert, ['j27'])
|
||||||
|
tokenNoCert = await getSessionToken(emailNoCert)
|
||||||
|
tokenCert = await getSessionToken(emailCert)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||||
|
await adminClient.from('boats').delete().eq('id', boatId)
|
||||||
|
await adminClient.auth.admin.deleteUser(userNoCert)
|
||||||
|
await adminClient.auth.admin.deleteUser(userCert)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects booking when member lacks required cert', async () => {
|
||||||
|
const slot = futureSlot(35)
|
||||||
|
const { status, body } = await callCreateReservation(tokenNoCert, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(422)
|
||||||
|
expect(body.error.code).toBe('cert_required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows booking when member has required cert', async () => {
|
||||||
|
const slot = futureSlot(36)
|
||||||
|
const { status } = await callCreateReservation(tokenCert, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows booking on a boat with no cert requirements', async () => {
|
||||||
|
const openBoat = await createTestBoat([])
|
||||||
|
const slot = futureSlot(37)
|
||||||
|
const { status } = await callCreateReservation(tokenNoCert, { boat_id: openBoat, ...slot })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
await adminClient.from('boats').delete().eq('id', openBoat)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Out-of-service check ──────────────────────────────────────────────────────
|
||||||
|
describe('boat out-of-service check', () => {
|
||||||
|
const email = `test-oos-${Date.now()}@oysqn.test`
|
||||||
|
let userId: string
|
||||||
|
let boatId: string
|
||||||
|
let token: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
boatId = await createTestBoat([], false) // booking_available = false
|
||||||
|
userId = await createTestUser(email)
|
||||||
|
token = await getSessionToken(email)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await adminClient.from('boats').delete().eq('id', boatId)
|
||||||
|
await adminClient.auth.admin.deleteUser(userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects booking when boat is out of service', async () => {
|
||||||
|
const slot = futureSlot(40)
|
||||||
|
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(422)
|
||||||
|
expect(body.error.code).toBe('boat_unavailable')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Weekly session limit ──────────────────────────────────────────────────────
|
||||||
|
describe('weekly session limit', () => {
|
||||||
|
const email = `test-weekly-${Date.now()}@oysqn.test`
|
||||||
|
let userId: string
|
||||||
|
let boatId: string
|
||||||
|
let token: string
|
||||||
|
|
||||||
|
// Use a fixed far-future week so existing data doesn't interfere
|
||||||
|
// Monday 2026-06-08 (week with no holidays)
|
||||||
|
const WEEK_BASE = new Date('2026-06-08T09:00:00Z')
|
||||||
|
|
||||||
|
function weekSlot(dayOffset: number, hour = 9): { start_time: string; end_time: string } {
|
||||||
|
const s = new Date(WEEK_BASE)
|
||||||
|
s.setUTCDate(WEEK_BASE.getUTCDate() + dayOffset)
|
||||||
|
s.setUTCHours(hour, 0, 0, 0)
|
||||||
|
const e = new Date(s)
|
||||||
|
e.setUTCHours(hour + 3, 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)
|
||||||
|
|
||||||
|
// Set max_sessions_per_week to 2 (should already be the default)
|
||||||
|
await adminClient.from('booking_config')
|
||||||
|
.upsert({ key: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' })
|
||||||
|
// Set advance hours to 1 so our far-future bookings count as pre-bookings
|
||||||
|
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)
|
||||||
|
// Restore default
|
||||||
|
await adminClient.from('booking_config')
|
||||||
|
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows first pre-booking of the week', async () => {
|
||||||
|
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(0) })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows second pre-booking of the week', async () => {
|
||||||
|
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(1) })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects third pre-booking of the week', async () => {
|
||||||
|
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) })
|
||||||
|
expect(status).toBe(422)
|
||||||
|
expect(body.error.code).toBe('booking_limit_weekly')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Weekend session limit ─────────────────────────────────────────────────────
|
||||||
|
describe('weekend session limit', () => {
|
||||||
|
const email = `test-weekend-${Date.now()}@oysqn.test`
|
||||||
|
let userId: string
|
||||||
|
let boatId: string
|
||||||
|
let token: string
|
||||||
|
|
||||||
|
// 2026-06-13 (Saturday) and 2026-06-14 (Sunday) — same 2-week period
|
||||||
|
// 2026-06-20 (Saturday) — same 2-week period as June 13 (period weeks = 2)
|
||||||
|
const SAT1 = '2026-06-13T09:00:00Z'
|
||||||
|
const SAT1_END = '2026-06-13T12:30:00Z'
|
||||||
|
const SUN1 = '2026-06-14T09:00:00Z'
|
||||||
|
const SUN1_END = '2026-06-14T12:30:00Z'
|
||||||
|
|
||||||
|
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('allows first weekend booking in the period', async () => {
|
||||||
|
const { status } = await callCreateReservation(token, {
|
||||||
|
boat_id: boatId, start_time: SAT1, end_time: SAT1_END,
|
||||||
|
})
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects second weekend booking in the same period', async () => {
|
||||||
|
const { status, body } = await callCreateReservation(token, {
|
||||||
|
boat_id: boatId, start_time: SUN1, end_time: SUN1_END,
|
||||||
|
})
|
||||||
|
expect(status).toBe(422)
|
||||||
|
expect(body.error.code).toBe('booking_limit_weekend')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Open-session window bypass ────────────────────────────────────────────────
|
||||||
|
describe('open-session window bypasses pre-booking limits', () => {
|
||||||
|
const email = `test-opensession-${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)
|
||||||
|
|
||||||
|
// Set limit to 0 pre-bookings/week so only open sessions can go through
|
||||||
|
await adminClient.from('booking_config')
|
||||||
|
.upsert({ key: 'max_sessions_per_week', value: 0 }, { onConflict: 'key' })
|
||||||
|
// Open session window: 9999 hours (everything is an open session)
|
||||||
|
await adminClient.from('booking_config')
|
||||||
|
.upsert({ key: 'open_session_advance_hours', value: 9999 }, { 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: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' })
|
||||||
|
await adminClient.from('booking_config')
|
||||||
|
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows booking even when pre-booking limit is 0, because slot is within open-session window', async () => {
|
||||||
|
const slot = futureSlot(1)
|
||||||
|
const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 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`
|
||||||
|
const emailOther = `test-rbac-other-${Date.now()}@oysqn.test`
|
||||||
|
let ownerUserId: string
|
||||||
|
let otherUserId: string
|
||||||
|
let boatId: string
|
||||||
|
let ownerToken: string
|
||||||
|
let otherToken: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
boatId = await createTestBoat()
|
||||||
|
ownerUserId = await createTestUser(emailOwner)
|
||||||
|
otherUserId = await createTestUser(emailOther)
|
||||||
|
ownerToken = await getSessionToken(emailOwner)
|
||||||
|
otherToken = await getSessionToken(emailOther)
|
||||||
|
|
||||||
|
// Create a reservation for ownerUser directly via admin
|
||||||
|
const slot = futureSlot(50)
|
||||||
|
await directInsertReservation(boatId, ownerUserId, slot.start_time, slot.end_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||||
|
await adminClient.from('boats').delete().eq('id', boatId)
|
||||||
|
await adminClient.auth.admin.deleteUser(ownerUserId)
|
||||||
|
await adminClient.auth.admin.deleteUser(otherUserId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('owner can see full reservation details', async () => {
|
||||||
|
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
global: { headers: { Authorization: `Bearer ${ownerToken}` } },
|
||||||
|
})
|
||||||
|
const { data, error } = await client.from('reservations').select('*').eq('boat_id', boatId)
|
||||||
|
expect(error).toBeNull()
|
||||||
|
expect(data).toHaveLength(1)
|
||||||
|
expect(data![0].user_id).toBeDefined()
|
||||||
|
expect(data![0].reason).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('other member sees the slot via reservation_slots (boat/time/status only)', async () => {
|
||||||
|
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
global: { headers: { Authorization: `Bearer ${otherToken}` } },
|
||||||
|
})
|
||||||
|
// reservation_slots view — should return the row
|
||||||
|
const { data: slots, error: slotsErr } = await client
|
||||||
|
.from('reservation_slots' as never)
|
||||||
|
.select('id, boat_id, start_time, end_time, status')
|
||||||
|
.eq('boat_id', boatId)
|
||||||
|
expect(slotsErr).toBeNull()
|
||||||
|
expect(slots).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('other member cannot see personal details of owner reservation via direct table query', async () => {
|
||||||
|
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
global: { headers: { Authorization: `Bearer ${otherToken}` } },
|
||||||
|
})
|
||||||
|
// Should return 0 rows — RLS "Users can read own reservations" blocks other members
|
||||||
|
const { data } = await client.from('reservations').select('*').eq('boat_id', boatId)
|
||||||
|
const ownerRows = (data ?? []).filter((r: { user_id: string }) => r.user_id === ownerUserId)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
6
tests/tsconfig.json
Normal file
6
tests/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
test: {
|
const env = loadEnv(mode ?? 'test', process.cwd(), '')
|
||||||
environment: 'node',
|
return {
|
||||||
include: ['tests/integration/**/*.test.ts'],
|
test: {
|
||||||
testTimeout: 30000,
|
environment: 'node',
|
||||||
},
|
include: ['tests/integration/**/*.test.ts'],
|
||||||
resolve: {
|
testTimeout: 30000,
|
||||||
alias: {
|
typecheck: { tsconfig: './tests/tsconfig.json' },
|
||||||
'~': new URL('./app', import.meta.url).pathname,
|
env,
|
||||||
},
|
},
|
||||||
},
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': new URL('./app', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2847,7 +2847,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types "~7.18.0"
|
undici-types "~7.18.0"
|
||||||
|
|
||||||
"@types/node@>=20.0.0":
|
"@types/node@>=20.0.0", "@types/node@^25.6.0":
|
||||||
version "25.6.0"
|
version "25.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca"
|
||||||
integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==
|
integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==
|
||||||
|
|||||||
Reference in New Issue
Block a user