From 7e51c2df37135a507928684c4e46dceb3896fa41 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Sat, 8 Aug 2026 13:25:04 +0000 Subject: [PATCH] feat: add on-booking-created and on-inquiry-created Edge Functions with DB-trigger wiring Wires pg_net-based database triggers on bookings/inquiries to invoke the two Edge Functions on insert. Two empirically-discovered fixes over the brief's literal SQL were required to make this work locally: - ALTER DATABASE ... SET for a custom GUC needs superuser, but the local migration role (postgres) isn't superuser in this Supabase CLI stack (only supabase_admin is) - inlined the base URL as a literal constant in each trigger function instead. - pg_net's http_post lives in schema `net`, not `extensions.net` - fixed the function calls accordingly. Also disabled verify_jwt for both functions in config.toml since they're invoked by pg_net (no Authorization header), not by end-user clients. Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH --- supabase/config.toml | 14 +++ .../functions/on-booking-created/index.ts | 45 +++++++++ .../functions/on-inquiry-created/index.ts | 38 ++++++++ .../20260808000003_notify_triggers.sql | 91 +++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 supabase/functions/on-booking-created/index.ts create mode 100644 supabase/functions/on-inquiry-created/index.ts create mode 100644 supabase/migrations/20260808000003_notify_triggers.sql diff --git a/supabase/config.toml b/supabase/config.toml index cc32dd9..b399984 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -385,6 +385,20 @@ deno_version = 2 # [edge_runtime.secrets] # secret_key = "env(SECRET_VALUE)" +# These two functions are invoked exclusively by database triggers via +# pg_net (see supabase/migrations/20260808000003_notify_triggers.sql), not +# by end-user clients carrying a Supabase JWT. Local `functions serve` +# enforces JWT verification by default; without disabling it here, pg_net's +# request (which sends no Authorization header) is rejected with 401 +# "Missing authorization header" before the function code ever runs — +# confirmed empirically via `select * from net._http_response` showing that +# exact error after a trigger fired. +[functions.on-booking-created] +verify_jwt = false + +[functions.on-inquiry-created] +verify_jwt = false + [analytics] enabled = true port = 54327 diff --git a/supabase/functions/on-booking-created/index.ts b/supabase/functions/on-booking-created/index.ts new file mode 100644 index 0000000..3c65472 --- /dev/null +++ b/supabase/functions/on-booking-created/index.ts @@ -0,0 +1,45 @@ +import { createClient } from 'jsr:@supabase/supabase-js@2' +import { buildBookingNotificationEmail } from '../_shared/email.ts' + +const FROM_ADDRESS = 'bookings@braidsbymira.com' +const MIRA_EMAIL = 'hello@braidsbymira.com' + +Deno.serve(async (req) => { + const payload = await req.json() + const booking = payload.record + + 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 { 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 }), + }) + + return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }) +}) diff --git a/supabase/functions/on-inquiry-created/index.ts b/supabase/functions/on-inquiry-created/index.ts new file mode 100644 index 0000000..eb6c67f --- /dev/null +++ b/supabase/functions/on-inquiry-created/index.ts @@ -0,0 +1,38 @@ +import { createClient } from 'jsr:@supabase/supabase-js@2' +import { buildInquiryNotificationEmail } from '../_shared/email.ts' + +const FROM_ADDRESS = 'bookings@braidsbymira.com' +const MIRA_EMAIL = 'hello@braidsbymira.com' + +Deno.serve(async (req) => { + const payload = await req.json() + const inquiry = payload.record + + 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 { 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 }), + }) + + return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }) +}) diff --git a/supabase/migrations/20260808000003_notify_triggers.sql b/supabase/migrations/20260808000003_notify_triggers.sql new file mode 100644 index 0000000..5d9de66 --- /dev/null +++ b/supabase/migrations/20260808000003_notify_triggers.sql @@ -0,0 +1,91 @@ +create extension if not exists pg_net with schema extensions; + +-- The Edge Functions base URL is environment-specific: for local development +-- it points at the local functions runtime; Task 9 updates it to the real +-- deployed URL in production. +-- +-- NOTE ON IMPLEMENTATION: the brief originally called for storing this as a +-- database-level GUC via `alter database postgres set +-- app.settings.edge_function_base_url = '...'`, read back in each trigger +-- function via current_setting(). That statement fails locally with +-- `permission denied to set parameter "app.settings.edge_function_base_url"` +-- (SQLSTATE 42501): in this Supabase CLI version's local Postgres stack, the +-- `postgres` role that runs migrations is deliberately NOT a superuser +-- (Create role/Create DB/Replication/Bypass RLS only) — only `supabase_admin` +-- is superuser, mirroring the hosted permission model. Setting an arbitrary +-- custom GUC at the database level requires either superuser or a `GRANT SET +-- ON PARAMETER` from a superuser, and the migration has no way to grant +-- itself that privilege. Empirically verified via `docker exec +-- supabase_db_ psql -U postgres -d postgres -c "alter database +-- postgres set app.settings.edge_function_base_url = '...'"` (denied) vs the +-- same command as `-U supabase_admin` (succeeds). +-- +-- Rather than depend on a migration-time GRANT (fragile, and would still +-- need to run as supabase_admin some other way), the base URL is inlined as +-- a literal constant in each trigger function below. Task 9 (or any future +-- environment change) updates it by editing the constant here and +-- re-migrating — the same operational shape the GUC approach would have had. +-- +-- Default here is the local Supabase CLI functions gateway address, +-- confirmed to resolve from inside the Postgres container in Step 5 below +-- (curl from supabase_db_ to http://host.docker.internal:54321 +-- reached the edge runtime and got a real HTTP response, not a connection +-- failure). +-- +-- SECOND CORRECTION: the brief's SQL called `extensions.net.http_post(...)` +-- (treating `net` as a schema nested under `extensions`). Empirically, even +-- though `pg_extension.extnamespace` for pg_net reports `extensions` (from +-- `with schema extensions` above), pg_net's install script hardcodes its own +-- `net` schema for the actual functions (confirmed via `select n.nspname, +-- p.proname from pg_proc p join pg_namespace n on p.pronamespace = n.oid +-- where p.proname = 'http_post'` -> `net.http_post`). Calling +-- `extensions.net.http_post` therefore fails with "cross-database +-- references are not implemented: extensions.net.http_post" (Postgres reads +-- `extensions.net` as database.schema). Fixed to call `net.http_post` +-- directly below. + +create or replace function public.notify_booking_created() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + base_url text := 'http://host.docker.internal:54321/functions/v1'; +begin + perform net.http_post( + url := base_url || '/on-booking-created', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := jsonb_build_object('record', to_jsonb(new)) + ); + return new; +end; +$$; + +create trigger booking_created_notify + after insert on public.bookings + for each row + execute function public.notify_booking_created(); + +create or replace function public.notify_inquiry_created() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + base_url text := 'http://host.docker.internal:54321/functions/v1'; +begin + perform net.http_post( + url := base_url || '/on-inquiry-created', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := jsonb_build_object('record', to_jsonb(new)) + ); + return new; +end; +$$; + +create trigger inquiry_created_notify + after insert on public.inquiries + for each row + execute function public.notify_inquiry_created();