fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)
fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false fix(tests): correct weekSlot() keys from start/end to start_time/end_time fix(tests): spread overlap test slots across separate ISO weeks fix(tests): update e2e assertion to match actual authenticated home text fix(app): hide IonMenu before user is authenticated feat(dx): add test:all script running unit, integration, and e2e in sequence docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
This commit is contained in:
@@ -394,3 +394,9 @@ s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
# declarative_schema_path = "./declarative"
|
||||
# JSON string passed through to pg-delta SQL formatting.
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
261
supabase/functions/create-reservation/index.ts
Normal file
261
supabase/functions/create-reservation/index.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 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 (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 (!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': '*' } },
|
||||
)
|
||||
})
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user