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

@@ -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' } })
})