fix: prevent double-booking, clean up booked_slots, harden Edge Function error handling
Fast follow-up to the Phase 2A backend review (4 deferred Important items):
1. Add a partial unique index (date, time) where status = 'confirmed' on
public.bookings so two concurrent bookings for the same slot can no longer
both succeed. Previously sync_booked_slot()'s `on conflict do nothing`
masked the second insert as a silent no-op on the mirror table instead of
rejecting it.
2. Extend sync_booked_slot() (new migration, the applied 20260808000002 is
left untouched) to also fire on `update of status` and `delete`, removing
the booked_slots mirror row once no confirmed booking remains for a slot.
Also tightened the insert path to only mirror bookings that are actually
'confirmed' -- the original trigger mirrored every insert regardless of
status, so a booking inserted directly with status='cancelled' (allowed by
the public insert policy) would incorrectly block a slot nobody confirmed.
3. supabase/functions/{on-booking-created,on-inquiry-created}: guard
req.json() and payload.record with try/catch (400 + console.error on
failure), check the Resend response's .ok and log failures instead of
swallowing them, and skip the Resend call entirely (with a log) instead of
sending "Authorization: Bearer undefined" when RESEND_API_KEY is unset.
Still return 200 when only the notification leg fails, since the booking/
inquiry row itself already committed successfully by the time this handler
runs -- a non-200 would make pg_net retry, but retrying re-sends against
the same already-inserted row and won't fix a Resend outage or bad
payload, so it isn't the right signal.
4. README: document the Supabase-side dev loop (supabase start/db reset/test
db), npm run seed and its SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY env vars,
and npm run test:functions (Deno tests for the Edge Functions' shared
email builders).
pgTAP suite grew from 12 to 23 tests (11 new: 4 for the uniqueness
constraint, 7 for booked_slots cleanup on cancel/delete). Verified locally:
supabase db reset, supabase test db (23/23 pass), vitest run (51/51 pass),
npm run build, npm run lint, and npm run test:functions (Deno installed for
this session; 6/6 pass) all succeed. Nothing here touches the live Supabase
project -- deploying is a separate follow-up step.
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
begin;
|
||||
select plan(12);
|
||||
select plan(23);
|
||||
|
||||
-- Seed reference data as service_role (bypasses RLS)
|
||||
set local role service_role;
|
||||
@@ -101,5 +101,75 @@ select lives_ok(
|
||||
'admin can insert into services'
|
||||
);
|
||||
|
||||
-- Double-booking prevention: only one *confirmed* booking may hold a given
|
||||
-- (date, time) slot. Uses service_role so grant-layer checks don't interfere
|
||||
-- with the uniqueness check under test.
|
||||
set local role service_role;
|
||||
|
||||
select lives_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
||||
values ('knotless', '2026-10-01', '2:00 PM', 'A Client', '555-0101', 'a@example.com', '', 'confirmed') $$,
|
||||
'first confirmed booking for a slot succeeds'
|
||||
);
|
||||
|
||||
select throws_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
||||
values ('knotless', '2026-10-01', '2:00 PM', 'B Client', '555-0102', 'b@example.com', '', 'confirmed') $$,
|
||||
'23505',
|
||||
null,
|
||||
'a second confirmed booking for an already-booked slot fails with a uniqueness violation'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
||||
values ('knotless', '2026-10-01', '3:00 PM', 'C Client', '555-0103', 'c@example.com', '', 'confirmed') $$,
|
||||
'a confirmed booking for a different slot on the same date succeeds'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status)
|
||||
values ('knotless', '2026-10-01', '2:00 PM', 'D Client', '555-0104', 'd@example.com', '', 'cancelled') $$,
|
||||
'a cancelled booking for an already-booked slot still succeeds (partial index only covers confirmed)'
|
||||
);
|
||||
|
||||
-- booked_slots cleanup: mirror row is removed once no confirmed booking
|
||||
-- remains for a (date, time), whether via status update or deletion.
|
||||
select isnt_empty(
|
||||
$$ select * from public.booked_slots where date = '2026-10-01' and time = '2:00 PM' $$,
|
||||
'booked_slots mirror has an entry for the confirmed slot'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ update public.bookings set status = 'cancelled'
|
||||
where date = '2026-10-01' and time = '2:00 PM' and status = 'confirmed' $$,
|
||||
'cancelling the confirmed booking succeeds'
|
||||
);
|
||||
|
||||
select is_empty(
|
||||
$$ select * from public.booked_slots where date = '2026-10-01' and time = '2:00 PM' $$,
|
||||
'booked_slots mirror row is removed after the booking is cancelled'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes)
|
||||
values ('knotless', '2026-10-02', '9:00 AM', 'E Client', '555-0105', 'e@example.com', '') $$,
|
||||
'confirmed booking for the deletion test succeeds'
|
||||
);
|
||||
|
||||
select isnt_empty(
|
||||
$$ select * from public.booked_slots where date = '2026-10-02' and time = '9:00 AM' $$,
|
||||
'booked_slots mirror has an entry before deletion'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ delete from public.bookings where date = '2026-10-02' and time = '9:00 AM' $$,
|
||||
'deleting the confirmed booking succeeds'
|
||||
);
|
||||
|
||||
select is_empty(
|
||||
$$ select * from public.booked_slots where date = '2026-10-02' and time = '9:00 AM' $$,
|
||||
'booked_slots mirror row is removed after the booking is deleted'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
|
||||
Reference in New Issue
Block a user