feat: Enhance reservation functionality
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
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[]
|
||||
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 {
|
||||
@@ -83,7 +84,7 @@ Deno.serve(async (req: Request) => {
|
||||
const { data: { user }, error: authError } = await adminClient.auth.getUser(token)
|
||||
if (authError || !user) return errorResponse('unauthorized', 'Invalid session', 401)
|
||||
|
||||
const userId = user.id
|
||||
const callerId = user.id
|
||||
|
||||
// Parse body
|
||||
let body: CreateReservationBody
|
||||
@@ -93,7 +94,18 @@ Deno.serve(async (req: Request) => {
|
||||
return errorResponse('invalid_body', 'Request body must be JSON', 400)
|
||||
}
|
||||
|
||||
const { boat_id, start_time, end_time, reason = '', comment = '', member_ids = [], guest_ids = [] } = body
|
||||
const { boat_id, start_time, end_time, reason = '', comment = '', member_ids = [], guest_ids = [], target_user_id } = body
|
||||
|
||||
// Resolve admin status once — used for limit bypass and target_user_id trust
|
||||
const { data: callerMember } = await adminClient
|
||||
.from('members')
|
||||
.select('role')
|
||||
.eq('user_id', callerId)
|
||||
.single()
|
||||
const isAdmin = callerMember?.role === 'admin'
|
||||
|
||||
// Effective user: admin may create on behalf of another member
|
||||
const userId = (isAdmin && target_user_id) ? target_user_id : callerId
|
||||
|
||||
if (!boat_id || !start_time || !end_time) {
|
||||
return errorResponse('missing_fields', 'boat_id, start_time, and end_time are required', 400)
|
||||
@@ -113,15 +125,8 @@ Deno.serve(async (req: Request) => {
|
||||
// ── 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)
|
||||
}
|
||||
if (isPast && !isAdmin) {
|
||||
return errorResponse('historical_booking_not_allowed', 'Can not book a reservation in the past.', 422)
|
||||
}
|
||||
|
||||
// ── 1. Load booking config ──────────────────────────────────────────────────
|
||||
@@ -156,7 +161,7 @@ Deno.serve(async (req: Request) => {
|
||||
if (boatErr || !boat) return errorResponse('not_found', 'Boat not found', 404)
|
||||
if (!boat.booking_available) return errorResponse('boat_unavailable', 'This boat is currently out of service', 422)
|
||||
|
||||
if (!isPast && boat.required_certs.length > 0) {
|
||||
if (!isAdmin && !isPast && boat.required_certs.length > 0) {
|
||||
const { data: member } = await adminClient
|
||||
.from('members')
|
||||
.select('certifications')
|
||||
@@ -178,7 +183,7 @@ Deno.serve(async (req: Request) => {
|
||||
const advanceMs = config.open_session_advance_hours * 60 * 60 * 1000
|
||||
const isOpenSession = startDate.getTime() - Date.now() <= advanceMs
|
||||
|
||||
if (!isPast && !isOpenSession) {
|
||||
if (!isAdmin && !isPast && !isOpenSession) {
|
||||
// ── 5. Weekly pre-booking limit ─────────────────────────────────────────
|
||||
const weekStart = isoWeekStart(startDate)
|
||||
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
@@ -187,6 +192,7 @@ Deno.serve(async (req: Request) => {
|
||||
.from('reservations')
|
||||
.select('start_time, created_at')
|
||||
.eq('user_id', userId)
|
||||
.neq('status', 'cancelled')
|
||||
.gte('start_time', weekStart.toISOString())
|
||||
.lt('start_time', weekEnd.toISOString())
|
||||
|
||||
@@ -219,6 +225,7 @@ Deno.serve(async (req: Request) => {
|
||||
.from('reservations')
|
||||
.select('start_time, created_at')
|
||||
.eq('user_id', userId)
|
||||
.neq('status', 'cancelled')
|
||||
.gte('start_time', periodStart.toISOString())
|
||||
.lt('start_time', periodEnd.toISOString())
|
||||
|
||||
@@ -253,7 +260,7 @@ Deno.serve(async (req: Request) => {
|
||||
comment,
|
||||
member_ids,
|
||||
guest_ids,
|
||||
status: 'pending',
|
||||
status: 'confirmed',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Allow members to cancel their own reservations and edit reason/comment.
|
||||
|
||||
-- 1. Expand status check constraint to include 'cancelled'
|
||||
alter table public.reservations
|
||||
drop constraint reservations_status_check;
|
||||
|
||||
alter table public.reservations
|
||||
add constraint reservations_status_check
|
||||
check (status in ('pending', 'tentative', 'confirmed', 'cancelled'));
|
||||
|
||||
-- 2. Exclude cancelled reservations from the public slots view so cancelled
|
||||
-- slots appear as available again on the schedule.
|
||||
drop view if exists public.reservation_slots;
|
||||
|
||||
create view public.reservation_slots
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select id, boat_id, start_time, end_time, status
|
||||
from public.reservations
|
||||
where status <> 'cancelled';
|
||||
|
||||
grant select on public.reservation_slots to authenticated;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- The overlap exclusion constraint must not apply to cancelled reservations,
|
||||
-- so that a cancelled slot can be re-booked by another member.
|
||||
|
||||
alter table public.reservations
|
||||
drop constraint no_overlapping_reservations;
|
||||
|
||||
alter table public.reservations
|
||||
add constraint no_overlapping_reservations
|
||||
exclude using gist (
|
||||
boat_id with =,
|
||||
tstzrange(start_time, end_time, '[)') with &&
|
||||
) where (status <> 'cancelled');
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Prevent members from modifying (cancelling or editing) reservations whose
|
||||
-- session has already started. Admins bypass this via service-role updates;
|
||||
-- the trigger only fires on connections where auth.uid() is a non-admin member.
|
||||
-- For simplicity we enforce it for all non-service-role connections.
|
||||
|
||||
create or replace function public.prevent_past_reservation_updates()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if old.start_time < now() then
|
||||
raise exception 'past_reservation: Reservations that have already started cannot be modified.';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger check_past_reservation_update
|
||||
before update on public.reservations
|
||||
for each row execute function public.prevent_past_reservation_updates();
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Expose user_id and member_name in reservation_slots.
|
||||
-- JOIN with members is safe here because security_invoker=false runs as view owner,
|
||||
-- bypassing RLS — so any authenticated user can see names without a separate members query.
|
||||
-- Must drop+recreate because CREATE OR REPLACE VIEW cannot insert a column mid-list.
|
||||
drop view if exists public.reservation_slots;
|
||||
|
||||
create view public.reservation_slots
|
||||
with (security_invoker = false)
|
||||
as
|
||||
select
|
||||
r.id,
|
||||
r.boat_id,
|
||||
r.user_id,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.status,
|
||||
coalesce(
|
||||
nullif(trim(m.first_name || ' ' || m.last_name), ''),
|
||||
m.email
|
||||
) as member_name
|
||||
from public.reservations r
|
||||
left join public.members m on m.user_id = r.user_id
|
||||
where r.status <> 'cancelled';
|
||||
|
||||
grant select on public.reservation_slots to authenticated;
|
||||
Reference in New Issue
Block a user