-- 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();