276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
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[]
|
|
}
|
|
|
|
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 userId = 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 = [] } = body
|
|
|
|
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) {
|
|
const { data: callerMember } = await adminClient
|
|
.from('members')
|
|
.select('role')
|
|
.eq('user_id', userId)
|
|
.single()
|
|
if (callerMember?.role !== 'admin') {
|
|
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 (!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 (!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)
|
|
.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)
|
|
.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: 'pending',
|
|
})
|
|
.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': '*' } },
|
|
)
|
|
})
|