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
This commit is contained in:
@@ -385,6 +385,20 @@ deno_version = 2
|
|||||||
# [edge_runtime.secrets]
|
# [edge_runtime.secrets]
|
||||||
# secret_key = "env(SECRET_VALUE)"
|
# 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]
|
[analytics]
|
||||||
enabled = true
|
enabled = true
|
||||||
port = 54327
|
port = 54327
|
||||||
|
|||||||
45
supabase/functions/on-booking-created/index.ts
Normal file
45
supabase/functions/on-booking-created/index.ts
Normal file
@@ -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' } })
|
||||||
|
})
|
||||||
38
supabase/functions/on-inquiry-created/index.ts
Normal file
38
supabase/functions/on-inquiry-created/index.ts
Normal file
@@ -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' } })
|
||||||
|
})
|
||||||
91
supabase/migrations/20260808000003_notify_triggers.sql
Normal file
91
supabase/migrations/20260808000003_notify_triggers.sql
Normal file
@@ -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_<project> 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_<project> 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();
|
||||||
Reference in New Issue
Block a user