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:
belviskhoremk
2026-08-12 10:29:37 +02:00
parent b4f7c1dce3
commit c74f1bb6cb
7 changed files with 369 additions and 58 deletions

View File

@@ -5,6 +5,23 @@ Public marketing site for Braids by Mira — landing page, product showcase, boo
## Scripts
- `npm run dev` — start the local dev server
- `npm test` (or `npx vitest run`) — run the test suite
- `npm test` (or `npx vitest run`) — run the frontend test suite
- `npm run build` — type-check and build for production
- `npm run lint` — run ESLint
- `npm run test:functions` — run the Deno tests for the Edge Functions' shared email builders (requires [Deno](https://deno.com) installed)
- `npm run seed` — seed products, services, and the default schedule into a Supabase project (see below)
## Backend (Supabase)
The backend lives in `supabase/` — Postgres schema/RLS migrations, pgTAP tests, and Deno Edge Functions for booking/inquiry email notifications.
- `npx supabase start` — start the local Docker-based Supabase dev stack (Postgres, Studio, Auth, Storage, Edge Functions runtime). Requires Docker.
- `npx supabase db reset` — replay all migrations from scratch against the local database.
- `npx supabase test db` — run the pgTAP test suite in `supabase/tests/database/` against the local database.
### Environment variables
Copy `.env.example` to `.env.local` and fill in:
- `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY` — frontend Supabase client config (exposed to the browser).
- `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` — used only by `scripts/seed.ts` (server-side, never expose the service role key to the browser). These are distinct from the `VITE_`-prefixed frontend variables above.

View File

@@ -2,7 +2,13 @@
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14"
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@supabase/supabase-js@2": "2.112.3",
"npm:@supabase/auth-js@2.112.3": "2.112.3",
"npm:@supabase/functions-js@2.112.3": "2.112.3",
"npm:@supabase/postgrest-js@2.112.3": "2.112.3",
"npm:@supabase/realtime-js@2.112.3": "2.112.3",
"npm:@supabase/storage-js@2.112.3": "2.112.3"
},
"jsr": {
"@std/assert@1.0.19": {
@@ -13,6 +19,59 @@
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@supabase/supabase-js@2.112.3": {
"integrity": "114dd7d327daa11fb5b876bc447c47a8c1b9bf3f0d0ed05b308a60639b134b7b",
"dependencies": [
"npm:@supabase/auth-js",
"npm:@supabase/functions-js",
"npm:@supabase/postgrest-js",
"npm:@supabase/realtime-js",
"npm:@supabase/storage-js"
]
}
},
"npm": {
"@supabase/auth-js@2.112.3": {
"integrity": "sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==",
"dependencies": [
"tslib"
]
},
"@supabase/functions-js@2.112.3": {
"integrity": "sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==",
"dependencies": [
"tslib"
]
},
"@supabase/phoenix@0.4.5": {
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw=="
},
"@supabase/postgrest-js@2.112.3": {
"integrity": "sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==",
"dependencies": [
"tslib"
]
},
"@supabase/realtime-js@2.112.3": {
"integrity": "sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==",
"dependencies": [
"@supabase/phoenix",
"tslib"
]
},
"@supabase/storage-js@2.112.3": {
"integrity": "sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==",
"dependencies": [
"iceberg-js",
"tslib"
]
},
"iceberg-js@0.8.1": {
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
}
}

View File

@@ -13,41 +13,77 @@ Deno.serve(async (req) => {
})
}
const payload = await req.json()
const booking = payload.record
let booking
try {
const payload = await req.json()
booking = payload?.record
if (!booking) {
throw new Error('payload.record is missing')
}
} catch (err) {
console.error('on-booking-created: failed to parse request body', err instanceof Error ? err.message : err)
return new Response(JSON.stringify({ error: 'invalid request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
try {
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
const { data: service } = await supabaseAdmin
.from('services')
.select('name')
.eq('id', booking.service_id)
.single()
const { data: service } = await supabaseAdmin
.from('services')
.select('name')
.eq('id', booking.service_id)
.single()
const { subject, html } = buildBookingNotificationEmail({
serviceName: service?.name ?? booking.service_id,
date: booking.date,
time: booking.time,
contact: {
name: booking.contact_name,
phone: booking.contact_phone,
email: booking.contact_email,
notes: booking.contact_notes,
},
})
const { subject, html } = buildBookingNotificationEmail({
serviceName: service?.name ?? booking.service_id,
date: booking.date,
time: booking.time,
contact: {
name: booking.contact_name,
phone: booking.contact_phone,
email: booking.contact_email,
notes: booking.contact_notes,
},
})
const resendApiKey = Deno.env.get('RESEND_API_KEY')
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }),
})
const resendApiKey = Deno.env.get('RESEND_API_KEY')
if (!resendApiKey) {
console.error(`on-booking-created: RESEND_API_KEY is not set, skipping email for service ${booking.service_id}`)
} else {
const resendRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }),
})
if (!resendRes.ok) {
console.error(
`on-booking-created: Resend send failed for service ${booking.service_id} (status ${resendRes.status})`,
await resendRes.text(),
)
}
}
} catch (err) {
// The booking itself already succeeded (this handler only runs after
// the row was inserted) -- a failure here means the notification email
// didn't go out, not that the booking failed. Log for debugging and
// still return 200: a non-200 would make pg_net treat this as a
// retry-worthy failure, but retrying re-sends against the same booking
// row and won't fix a Resend outage or bad payload.
console.error(
`on-booking-created: unexpected error for service ${booking?.service_id}`,
err instanceof Error ? err.message : err,
)
}
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } })
})

View File

@@ -13,34 +13,70 @@ Deno.serve(async (req) => {
})
}
const payload = await req.json()
const inquiry = payload.record
let inquiry
try {
const payload = await req.json()
inquiry = payload?.record
if (!inquiry) {
throw new Error('payload.record is missing')
}
} catch (err) {
console.error('on-inquiry-created: failed to parse request body', err instanceof Error ? err.message : err)
return new Response(JSON.stringify({ error: 'invalid request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
try {
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
const { data: product } = await supabaseAdmin
.from('products')
.select('name')
.eq('id', inquiry.product_id)
.single()
const { data: product } = await supabaseAdmin
.from('products')
.select('name')
.eq('id', inquiry.product_id)
.single()
const { subject, html } = buildInquiryNotificationEmail({
productName: product?.name ?? inquiry.product_id,
contact: { name: inquiry.contact_name, email: inquiry.contact_email, notes: inquiry.contact_notes },
})
const { subject, html } = buildInquiryNotificationEmail({
productName: product?.name ?? inquiry.product_id,
contact: { name: inquiry.contact_name, email: inquiry.contact_email, notes: inquiry.contact_notes },
})
const resendApiKey = Deno.env.get('RESEND_API_KEY')
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }),
})
const resendApiKey = Deno.env.get('RESEND_API_KEY')
if (!resendApiKey) {
console.error(`on-inquiry-created: RESEND_API_KEY is not set, skipping email for product ${inquiry.product_id}`)
} else {
const resendRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }),
})
if (!resendRes.ok) {
console.error(
`on-inquiry-created: Resend send failed for product ${inquiry.product_id} (status ${resendRes.status})`,
await resendRes.text(),
)
}
}
} catch (err) {
// The inquiry itself already succeeded (this handler only runs after
// the row was inserted) -- a failure here means the notification email
// didn't go out, not that the inquiry failed. Log for debugging and
// still return 200: a non-200 would make pg_net treat this as a
// retry-worthy failure, but retrying re-sends against the same inquiry
// row and won't fix a Resend outage or bad payload.
console.error(
`on-inquiry-created: unexpected error for product ${inquiry?.product_id}`,
err instanceof Error ? err.message : err,
)
}
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } })
})

View File

@@ -0,0 +1,16 @@
-- Follow-up to the final whole-branch review before merge (deferred, fast
-- follow-up task): public.bookings had no uniqueness constraint on
-- (date, time), so two concurrent bookings for the same slot could both
-- insert successfully -- the second one would silently no-op on the
-- booked_slots mirror (sync_booked_slot()'s `on conflict do nothing`)
-- instead of failing, leaving the app's slot-availability check unreliable
-- under concurrency.
--
-- A partial unique index on confirmed bookings only (not a plain unique
-- constraint on the whole table) is required because status can be
-- 'cancelled': a cancelled booking must not permanently block the slot it
-- once held, and multiple cancelled bookings may legitimately share a
-- (date, time) (e.g. several people asked for the same slot, all declined).
create unique index bookings_active_slot_idx
on public.bookings (date, time)
where status = 'confirmed';

View File

@@ -0,0 +1,77 @@
-- 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. Two 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.
--
-- Fix: the trigger function now branches on TG_OP. On insert/update it only
-- mirrors the slot when the new row is 'confirmed' (fixing #2 above). On
-- update-away-from-confirmed or delete, it removes the 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
if old.status = 'confirmed' and new.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;
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;
$$;
create trigger booking_status_updated_sync_slot
after update of status 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();

View File

@@ -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;