fix: address review — reschedule desync, pg_net comment, self-healing unique index

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
This commit is contained in:
belviskhoremk
2026-08-12 10:42:25 +02:00
parent c74f1bb6cb
commit e6061d7ba4
6 changed files with 289 additions and 42 deletions

View File

@@ -28,12 +28,27 @@ Deno.serve(async (req) => {
})
}
try {
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
// Best-effort: records a failure so it's visible somewhere a human could
// plausibly check (e.g. a future admin panel view), rather than only in
// Edge Function logs nobody reads. Swallow any error from this insert
// itself -- we're already in a failure path and must not let logging the
// failure become a second, louder failure.
const recordFailure = async (message: string) => {
try {
await supabaseAdmin
.from('notification_failures')
.insert({ kind: 'inquiry', record_id: inquiry.id, error_message: message })
} catch (err) {
console.error('on-inquiry-created: failed to record notification_failures row', err instanceof Error ? err.message : err)
}
}
try {
const { data: product } = await supabaseAdmin
.from('products')
.select('name')
@@ -47,7 +62,9 @@ Deno.serve(async (req) => {
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}`)
const message = 'RESEND_API_KEY is not set'
console.error(`on-inquiry-created: ${message}, skipping email for product ${inquiry.product_id}`)
await recordFailure(message)
} else {
const resendRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
@@ -59,23 +76,28 @@ Deno.serve(async (req) => {
})
if (!resendRes.ok) {
const body = await resendRes.text()
console.error(
`on-inquiry-created: Resend send failed for product ${inquiry.product_id} (status ${resendRes.status})`,
await resendRes.text(),
body,
)
await recordFailure(`Resend responded ${resendRes.status}: ${body}`)
}
}
} 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,
)
// didn't go out, not that the inquiry failed. We still return 200:
// pg_net is fire-and-forget (it doesn't inspect the response status or
// retry on non-2xx -- the response just lands in net._http_response),
// so returning 500 wouldn't trigger a retry or help anyone; nothing
// consumes this status code. What it *would* do is misrepresent the
// request as failed when the inquiry itself succeeded. The actual gap
// this creates -- a failed send being invisible -- is what
// notification_failures (recorded above/below) exists to close.
const message = err instanceof Error ? err.message : String(err)
console.error(`on-inquiry-created: unexpected error for product ${inquiry?.product_id}`, message)
await recordFailure(message)
}
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } })