Merge branch 'worktree-agent-a0cf8ec6e0b24c49d'
This commit is contained in:
19
README.md
19
README.md
@@ -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.
|
||||
|
||||
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,99 @@ 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')!,
|
||||
)
|
||||
|
||||
const { data: service } = await supabaseAdmin
|
||||
.from('services')
|
||||
.select('name')
|
||||
.eq('id', booking.service_id)
|
||||
.single()
|
||||
// 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: 'booking', record_id: booking.id, error_message: message })
|
||||
} catch (err) {
|
||||
console.error('on-booking-created: failed to record notification_failures row', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
try {
|
||||
const { data: service } = await supabaseAdmin
|
||||
.from('services')
|
||||
.select('name')
|
||||
.eq('id', booking.service_id)
|
||||
.single()
|
||||
|
||||
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 { 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')
|
||||
if (!resendApiKey) {
|
||||
const message = 'RESEND_API_KEY is not set'
|
||||
console.error(`on-booking-created: ${message}, skipping email for service ${booking.service_id}`)
|
||||
await recordFailure(message)
|
||||
} 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) {
|
||||
const body = await resendRes.text()
|
||||
console.error(
|
||||
`on-booking-created: Resend send failed for service ${booking.service_id} (status ${resendRes.status})`,
|
||||
body,
|
||||
)
|
||||
await recordFailure(`Resend responded ${resendRes.status}: ${body}`)
|
||||
}
|
||||
}
|
||||
} 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. 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 booking 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-booking-created: unexpected error for service ${booking?.service_id}`, message)
|
||||
await recordFailure(message)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } })
|
||||
})
|
||||
|
||||
@@ -13,34 +13,92 @@ 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')!,
|
||||
)
|
||||
|
||||
const { data: product } = await supabaseAdmin
|
||||
.from('products')
|
||||
.select('name')
|
||||
.eq('id', inquiry.product_id)
|
||||
.single()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
const { subject, html } = buildInquiryNotificationEmail({
|
||||
productName: product?.name ?? inquiry.product_id,
|
||||
contact: { name: inquiry.contact_name, email: inquiry.contact_email, notes: inquiry.contact_notes },
|
||||
})
|
||||
try {
|
||||
const { data: product } = await supabaseAdmin
|
||||
.from('products')
|
||||
.select('name')
|
||||
.eq('id', inquiry.product_id)
|
||||
.single()
|
||||
|
||||
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 { 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')
|
||||
if (!resendApiKey) {
|
||||
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',
|
||||
headers: {
|
||||
Authorization: `Bearer ${resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }),
|
||||
})
|
||||
|
||||
if (!resendRes.ok) {
|
||||
const body = await resendRes.text()
|
||||
console.error(
|
||||
`on-inquiry-created: Resend send failed for product ${inquiry.product_id} (status ${resendRes.status})`,
|
||||
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. 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' } })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 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).
|
||||
--
|
||||
-- SELF-HEALING: on `supabase db push` against the live project, this
|
||||
-- `create unique index` could fail outright if any existing confirmed
|
||||
-- booking already shares a (date, time) with another -- exactly the bug
|
||||
-- being fixed here, so entirely plausible in already-live data. Rather than
|
||||
-- have the migration fail and block deploy, dedupe_confirmed_booking_slots()
|
||||
-- below collapses each duplicate group down to the earliest-created row
|
||||
-- (keeping it 'confirmed') and cancels the rest before the index is
|
||||
-- created, so index creation is guaranteed to succeed either way. It's
|
||||
-- defined as a standalone function (called once below, but also directly
|
||||
-- callable) so the pgTAP test can exercise the exact same logic the
|
||||
-- migration runs, rather than a copy that could drift from it.
|
||||
create or replace function public.dedupe_confirmed_booking_slots()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
dup record;
|
||||
begin
|
||||
for dup in
|
||||
select date, time, count(*) as cnt
|
||||
from public.bookings
|
||||
where status = 'confirmed'
|
||||
group by date, time
|
||||
having count(*) > 1
|
||||
loop
|
||||
update public.bookings
|
||||
set status = 'cancelled'
|
||||
where date = dup.date and time = dup.time and status = 'confirmed'
|
||||
and id <> (
|
||||
select id from public.bookings
|
||||
where date = dup.date and time = dup.time and status = 'confirmed'
|
||||
order by created_at asc, id asc
|
||||
limit 1
|
||||
);
|
||||
raise notice 'dedupe_confirmed_booking_slots: cancelled % duplicate confirmed booking(s) for slot % %, keeping the earliest-created one',
|
||||
dup.cnt - 1, dup.date, dup.time;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.dedupe_confirmed_booking_slots() from public, anon, authenticated;
|
||||
grant execute on function public.dedupe_confirmed_booking_slots() to service_role;
|
||||
|
||||
select public.dedupe_confirmed_booking_slots();
|
||||
|
||||
create unique index bookings_active_slot_idx
|
||||
on public.bookings (date, time)
|
||||
where status = 'confirmed';
|
||||
94
supabase/migrations/20260812000003_booked_slots_cleanup.sql
Normal file
94
supabase/migrations/20260812000003_booked_slots_cleanup.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- 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();
|
||||
31
supabase/migrations/20260812000004_notification_failures.sql
Normal file
31
supabase/migrations/20260812000004_notification_failures.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Follow-up to the final review of this fast-follow-up branch. The Edge
|
||||
-- Functions (on-booking-created, on-inquiry-created) already log a failed
|
||||
-- Resend send via console.error, but Edge Function logs are not something
|
||||
-- Mira (the salon owner) would ever look at -- a failed notification email
|
||||
-- was effectively invisible. This table gives failures a place a human
|
||||
-- could plausibly check (e.g. surfaced later in an admin panel), without
|
||||
-- making the booking/inquiry request itself fail or retry (see the
|
||||
-- reasoning in the Edge Functions' try/catch blocks).
|
||||
create table public.notification_failures (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
kind text not null check (kind in ('booking', 'inquiry')),
|
||||
record_id uuid not null,
|
||||
error_message text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.notification_failures enable row level security;
|
||||
|
||||
-- Admin-only select; no insert/update/delete policy exists for anyone --
|
||||
-- only the Edge Functions' service-role client writes here, and
|
||||
-- service_role bypasses RLS entirely (same pattern as booked_slots'
|
||||
-- security-definer-trigger-only write path).
|
||||
create policy "notification_failures_admin_read" on public.notification_failures
|
||||
for select using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid);
|
||||
|
||||
-- No grant to anon at all (these rows can reference booking/inquiry PII via
|
||||
-- error_message, same protection level as bookings/inquiries themselves).
|
||||
-- authenticated gets select (narrowed to the admin UID by the RLS policy
|
||||
-- above); only service_role can write.
|
||||
grant select on public.notification_failures to authenticated;
|
||||
grant select, insert, update, delete on public.notification_failures to service_role;
|
||||
@@ -1,5 +1,5 @@
|
||||
begin;
|
||||
select plan(12);
|
||||
select plan(37);
|
||||
|
||||
-- Seed reference data as service_role (bypasses RLS)
|
||||
set local role service_role;
|
||||
@@ -101,5 +101,182 @@ 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'
|
||||
);
|
||||
|
||||
-- Rescheduling: an update that changes date/time while status stays
|
||||
-- 'confirmed' must free the OLD slot's mirror row and create one for the
|
||||
-- NEW slot. A trigger scoped to `after update of status` would never fire
|
||||
-- for this (status doesn't change), which was the bug caught in review.
|
||||
select lives_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes)
|
||||
values ('knotless', '2026-10-03', '11:00 AM', 'F Client', '555-0106', 'f@example.com', '') $$,
|
||||
'confirmed booking for the reschedule test succeeds'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ update public.bookings set date = '2026-10-04', time = '1:00 PM'
|
||||
where date = '2026-10-03' and time = '11:00 AM' and status = 'confirmed' $$,
|
||||
'rescheduling the confirmed booking to a new date/time succeeds'
|
||||
);
|
||||
|
||||
select is_empty(
|
||||
$$ select * from public.booked_slots where date = '2026-10-03' and time = '11:00 AM' $$,
|
||||
'booked_slots mirror row for the OLD slot is removed after a reschedule'
|
||||
);
|
||||
|
||||
select isnt_empty(
|
||||
$$ select * from public.booked_slots where date = '2026-10-04' and time = '1:00 PM' $$,
|
||||
'booked_slots mirror row for the NEW slot is created after a reschedule'
|
||||
);
|
||||
|
||||
-- notification_failures: admin-only select; only service_role writes.
|
||||
select lives_ok(
|
||||
$$ insert into public.notification_failures (kind, record_id, error_message)
|
||||
values ('booking', gen_random_uuid(), 'Resend responded 401: unauthorized') $$,
|
||||
'service_role can insert into notification_failures'
|
||||
);
|
||||
|
||||
set local role anon;
|
||||
select throws_ok(
|
||||
$$ select * from public.notification_failures $$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot read notification_failures'
|
||||
);
|
||||
|
||||
select throws_ok(
|
||||
$$ insert into public.notification_failures (kind, record_id, error_message)
|
||||
values ('booking', gen_random_uuid(), 'hack') $$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot insert into notification_failures'
|
||||
);
|
||||
|
||||
set local role authenticated;
|
||||
set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef';
|
||||
select isnt_empty(
|
||||
$$ select * from public.notification_failures $$,
|
||||
'admin can read notification_failures'
|
||||
);
|
||||
|
||||
-- Self-healing unique index migration: replay the exact dedupe logic
|
||||
-- 20260812000002 runs before creating bookings_active_slot_idx, proving
|
||||
-- that pre-existing duplicate confirmed bookings for a slot get collapsed
|
||||
-- to a single confirmed row (the earliest-created one) rather than making
|
||||
-- the index creation fail outright.
|
||||
-- DDL (drop/create index) requires table ownership, which service_role
|
||||
-- doesn't have -- reset to the connecting (owner) role for this block. The
|
||||
-- dedupe_confirmed_booking_slots() call further down still exercises the
|
||||
-- service_role grant path via an explicit role switch.
|
||||
reset role;
|
||||
|
||||
select lives_ok(
|
||||
$$ drop index if exists public.bookings_active_slot_idx $$,
|
||||
'temporarily dropping the unique index to simulate pre-migration data succeeds'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, created_at)
|
||||
values
|
||||
('knotless', '2026-11-01', '4:00 PM', 'Dup Earliest', '555-0201', 'dup1@example.com', '', 'confirmed', now() - interval '1 hour'),
|
||||
('knotless', '2026-11-01', '4:00 PM', 'Dup Later', '555-0202', 'dup2@example.com', '', 'confirmed', now()) $$,
|
||||
'seeding two duplicate confirmed bookings for the same slot succeeds without the unique index'
|
||||
);
|
||||
|
||||
-- Exercises the same execute grant the migration relies on: only
|
||||
-- service_role can call this function (revoked from anon/authenticated).
|
||||
set local role service_role;
|
||||
select lives_ok(
|
||||
$$ select public.dedupe_confirmed_booking_slots() $$,
|
||||
'dedupe_confirmed_booking_slots() runs without error'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select count(*)::int from public.bookings where date = '2026-11-01' and time = '4:00 PM' and status = 'confirmed'),
|
||||
1,
|
||||
'the self-heal dedupe leaves exactly one confirmed booking per duplicated slot'
|
||||
);
|
||||
|
||||
select results_eq(
|
||||
$$ select contact_name from public.bookings where date = '2026-11-01' and time = '4:00 PM' and status = 'confirmed' $$,
|
||||
$$ values ('Dup Earliest'::text) $$,
|
||||
'the self-heal dedupe keeps the earliest-created booking as confirmed'
|
||||
);
|
||||
|
||||
-- Back to the owner role for the final DDL step.
|
||||
reset role;
|
||||
select lives_ok(
|
||||
$$ create unique index bookings_active_slot_idx on public.bookings (date, time) where status = 'confirmed' $$,
|
||||
'the unique index can be created after the self-heal dedupe removes the duplicate'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
|
||||
Reference in New Issue
Block a user