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): 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)[k] = Number(row.value) } // ── 2. Load holidays ──────────────────────────────────────────────────────── const { data: holidayRows } = await adminClient.from('holidays').select('date') const holidays = new Set((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': '*' } }, ) })