Fixes 3 Important issues from re-review of the previous fast-follow-up commit: 1. booked_slots desync on reschedule: the cleanup trigger was `after update of status`, so changing date/time on a still-confirmed booking never fired it -- the OLD slot stayed blocked and the NEW slot stayed shown as free. Changed to an unqualified `after update`, and the UPDATE branch now compares the full (date, time, status) triple instead of just status, so it frees the old slot on any change away from it and (re)mirrors the new one when the row is still/now confirmed. Added pgTAP coverage: reschedule removes the old slot's mirror row and creates the new one. 2. The Edge Functions' comments/commit message claimed a non-200 response would make pg_net "retry" -- that's not how pg_net works, it's fire-and-forget and never inspects the response status. Corrected the comments to state the real reasoning (nothing consumes the status code, so a 500 wouldn't help and would misrepresent an already-successful booking/inquiry as failed). Closed the actual gap that created -- a failed Resend send was only visible in Edge Function logs nobody reads -- by adding a `notification_failures` table (new migration, admin-only select via RLS, no anon/authenticated write access, only service_role writes) and having both handlers insert a row (booking/inquiry id + error message) whenever the Resend call fails, the API key is missing, or an unexpected error occurs. Insert failures are swallowed (best-effort -- already in a failure path, must not throw a louder one). Added pgTAP tests for the table's RLS/grant shape. 3. `20260812000002_bookings_slot_uniqueness.sql` could have failed outright on `supabase db push` against live data if any existing confirmed booking already shared a (date, time) with another. Added `dedupe_confirmed_booking_slots()`, called before the index is created: it collapses each duplicate group down to the earliest-created row (kept 'confirmed', others cancelled) and logs what it did via `raise notice`, so index creation is guaranteed to succeed either way. Extracted as a standalone, service_role-only-executable function (rather than inlining the SQL) specifically so the pgTAP test can exercise the exact same logic the migration runs. Added a test that seeds two duplicate confirmed bookings (having first dropped the index to simulate the pre-migration state), runs the dedupe function, and asserts exactly one confirmed booking remains -- the earliest one -- before recreating the index. pgTAP suite grew from 23 to 37 tests. Verified locally: supabase db reset (all 7 migrations apply cleanly), supabase test db (37/37 pass), vitest run (51/51 pass), npm run build, npm run lint, and npm run test:functions (6/6 pass) all succeed. Still nothing here touches the live Supabase project. Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
95 lines
4.0 KiB
PL/PgSQL
95 lines
4.0 KiB
PL/PgSQL
-- Follow-up to 20260808000002 (deferred from the final whole-branch review
|
|
-- before merge). Postgres migrations are append-only history, so this adds
|
|
-- new triggers and replaces the trigger function rather than editing the
|
|
-- already-applied 20260808000002 file.
|
|
--
|
|
-- Problem: sync_booked_slot() only ran `after insert`, and unconditionally
|
|
-- mirrored every new booking regardless of status. Three consequences:
|
|
-- 1. If a confirmed booking was later cancelled (status update) or
|
|
-- deleted, its booked_slots mirror row was never removed, so the slot
|
|
-- stayed permanently blocked even though it was actually free again.
|
|
-- 2. A booking inserted directly with status = 'cancelled' (the public
|
|
-- insert policy allows any status, not just the 'confirmed' default)
|
|
-- would still create a booked_slots row, incorrectly blocking a slot
|
|
-- nobody had actually confirmed.
|
|
-- 3. Rescheduling a confirmed booking (changing date/time while it stays
|
|
-- 'confirmed') didn't fire the trigger at all under an `of status`
|
|
-- qualifier, leaving the OLD slot permanently blocked and the NEW slot
|
|
-- showing as free even though it's now taken. Caught in review: a
|
|
-- status-qualified trigger silently misses any update that doesn't
|
|
-- touch the status column.
|
|
--
|
|
-- Fix: the trigger function now branches on TG_OP and the trigger fires on
|
|
-- every UPDATE (not just `of status`). On insert/update it only mirrors the
|
|
-- slot when the new row is 'confirmed' (fixing #2 above). On update where
|
|
-- the (date, time, status) triple actually changed and the OLD row was
|
|
-- confirmed, or on delete of a confirmed row, it removes the OLD slot's
|
|
-- mirror row, but only if no *other* confirmed booking still holds that
|
|
-- (date, time) -- the partial unique index added in 20260812000002
|
|
-- guarantees there can be at most one, but this guard keeps the function
|
|
-- correct even if that assumption ever changes.
|
|
create or replace function public.sync_booked_slot()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
if tg_op = 'DELETE' then
|
|
if old.status = 'confirmed' then
|
|
delete from public.booked_slots
|
|
where date = old.date and time = old.time
|
|
and not exists (
|
|
select 1 from public.bookings b
|
|
where b.date = old.date and b.time = old.time and b.status = 'confirmed'
|
|
);
|
|
end if;
|
|
return old;
|
|
end if;
|
|
|
|
if tg_op = 'UPDATE' then
|
|
-- Covers both a status change away from 'confirmed' (cancellation) and a
|
|
-- reschedule of a still-confirmed booking to a different (date, time):
|
|
-- either way, the OLD slot may no longer have any confirmed booking
|
|
-- holding it and its mirror row needs to go.
|
|
if old.status = 'confirmed'
|
|
and (old.date, old.time, old.status) is distinct from (new.date, new.time, new.status) then
|
|
delete from public.booked_slots
|
|
where date = old.date and time = old.time
|
|
and not exists (
|
|
select 1 from public.bookings b
|
|
where b.date = old.date and b.time = old.time and b.status = 'confirmed'
|
|
);
|
|
end if;
|
|
if new.status = 'confirmed' then
|
|
insert into public.booked_slots (date, time)
|
|
values (new.date, new.time)
|
|
on conflict do nothing;
|
|
end if;
|
|
return new;
|
|
end if;
|
|
|
|
-- INSERT
|
|
if new.status = 'confirmed' then
|
|
insert into public.booked_slots (date, time)
|
|
values (new.date, new.time)
|
|
on conflict do nothing;
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
-- Unqualified `after update` (not `of status`): a reschedule that changes
|
|
-- date/time while status stays 'confirmed' must also resync the mirror --
|
|
-- the old slot needs freeing and the new slot needs blocking -- and an
|
|
-- `of status` trigger would never fire for that case.
|
|
create trigger booking_updated_sync_slot
|
|
after update on public.bookings
|
|
for each row
|
|
execute function public.sync_booked_slot();
|
|
|
|
create trigger booking_deleted_sync_slot
|
|
after delete on public.bookings
|
|
for each row
|
|
execute function public.sync_booked_slot();
|