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:
61
supabase/functions/deno.lock
generated
61
supabase/functions/deno.lock
generated
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' } })
|
||||
})
|
||||
|
||||
@@ -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' } })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user