# Phase 2A: Backend Foundation on Supabase Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Task 8 is a manual, human-only task — do not dispatch it to a subagent.** It requires creating a live Supabase project and a Resend account, which no agent can do. Every task after it depends on values gathered there; the controller must carry those values into Task 9's dispatch context. **Goal:** Replace the Firebase-based backend foundation with an equivalent one built on Supabase (Postgres, Auth, Storage, Edge Functions) — chosen because its free tier needs no billing card for outbound network calls from serverless functions, and its Docker-based local emulator lets almost this entire sub-project be built and tested for real, without a live account. **Architecture:** Postgres tables (via Supabase) hold products/services/schedule as public-read/admin-write, and bookings/inquiries as public-insert/admin-read-only (PII protection, enforced by RLS policies and native column constraints). A `security definer` trigger keeps a PII-free `booked_slots` mirror in sync in pure SQL. A second trigger fires an HTTP call (via the `pg_net` extension) to a Supabase Edge Function on every booking/inquiry insert, which emails Mira via Resend. **Tech Stack:** Supabase (Postgres, Auth, Storage, Edge Functions), Supabase CLI + Docker (local dev/test), pgTAP (RLS/trigger tests), Deno's built-in test runner (Edge Function tests), Resend (email), `@supabase/supabase-js` (client SDK). ## Global Constraints - No file under `src/pages/`, `src/components/`, or `src/data/` is modified in this plan — Sub-project A only adds/removes backend files. The current mock-data-driven site must keep building and passing its existing test suite (`npx vitest run`, `npm run build`, `npm run lint`) unchanged after every task. - `bookings` and `inquiries` rows contain client PII (name, phone, email) and must never be publicly readable — only the admin (a single fixed UUID) may `SELECT` them. This is verified with pgTAP tests against a real local Postgres instance, not just asserted. - `booked_slots` is written only by a `security definer` trigger function — never by client code or a client-facing RLS policy — so it can safely be public-read without exposing PII. - Supabase project credentials and the Resend API key are never committed — they live in `.env.local` (gitignored) and Supabase Edge Function secrets respectively. - All Firebase-specific code from the previous (superseded) implementation of this sub-project is removed as part of this plan, not left dangling alongside the new Supabase code. - Palette/typography/existing site behavior: untouched by this plan (it's backend-only). --- ### Task 1: Remove the Firebase-based backend code **Files:** - Delete: `.firebaserc`, `firebase.json`, `firestore.indexes.json`, `firestore.rules`, `storage.rules`, `vitest.rules.config.ts`, `.env.example`, `docs/superpowers/PHASE2A_REMAINING.md` - Delete: `firebase-tests/` (whole directory) - Delete: `functions/` (whole directory) - Delete: `scripts/seed.ts` - Delete: `src/lib/firebase.ts`, `src/lib/firebase.test.ts` - Modify: `package.json` (remove `firebase` dependency, remove `test:rules` and `seed` scripts) - Modify: `.gitignore` (remove the now-unused `functions/lib`/`functions/node_modules` entries; keep `.env.local`/`.env.*.local`, they're still needed) **Interfaces:** - Produces: a clean slate with zero Firebase references, ready for Task 2 onward to build the Supabase equivalent. - [ ] **Step 1: Delete the Firebase-specific files and directories** ```bash rm -rf .firebaserc firebase.json firestore.indexes.json firestore.rules storage.rules \ vitest.rules.config.ts .env.example docs/superpowers/PHASE2A_REMAINING.md \ firebase-tests functions scripts/seed.ts \ src/lib/firebase.ts src/lib/firebase.test.ts ``` - [ ] **Step 2: Remove the `firebase` dependency and the two Firebase-only scripts from `package.json`** Open `package.json`. Remove the `"firebase": "^12.17.1"` line from `"dependencies"` (exact version may differ — remove whatever is there). Remove the `"test:rules"` and `"seed"` lines from `"scripts"`. Then run: ```bash npm install ``` This regenerates `package-lock.json` without the `firebase` package. - [ ] **Step 3: Clean up `.gitignore`** Remove these two lines (added for the now-deleted `functions/` directory): ``` functions/lib functions/node_modules ``` Keep everything else, including the `.env.local`/`.env.*.local` block — those are still needed for Supabase config. - [ ] **Step 4: Verify the existing app suite is completely unaffected** Run: `npx vitest run && npm run build && npm run lint` Expected: all pass — 51 tests, clean build, zero lint errors (this task only removes backend-only files, nothing the frontend imports). - [ ] **Step 5: Verify no Firebase references remain** Run: `grep -ril firebase . --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.superpowers` Expected: no output (or only matches inside `docs/superpowers/specs/2026-08-04-phase2-backend-foundation-design.md`, which is historical record and stays — do not delete or edit spec documents). - [ ] **Step 6: Commit** ```bash git add -A git commit -m "chore: remove Firebase backend code, replaced by Supabase (see 2026-08-08 spec)" ``` --- ### Task 2: Supabase CLI init and client SDK **Files:** - Create: `supabase/config.toml` (via `supabase init`) - Modify: `.gitignore` (add Supabase CLI local artifacts) - Modify: `package.json` (new dependency) - Create: `.env.example` - Create: `src/lib/supabase.ts` - Test: `src/lib/supabase.test.ts` **Interfaces:** - Produces: `src/lib/supabase.ts` exports `supabase` — the only export later sub-projects import from this module — a configured `SupabaseClient` instance. - [ ] **Step 1: Initialize the Supabase project scaffold** ```bash npx supabase init ``` This creates `supabase/config.toml` and a `supabase/` directory structure. When prompted about anything interactive, accept the defaults. - [ ] **Step 2: Add Supabase CLI-generated local artifacts to `.gitignore`** Append: ``` # Supabase CLI local artifacts supabase/.branches supabase/.temp ``` (Do NOT gitignore `supabase/migrations/`, `supabase/functions/`, or `supabase/tests/` — those are source code this plan commits.) - [ ] **Step 3: Install the Supabase JS client** ```bash npm install @supabase/supabase-js ``` - [ ] **Step 4: Create `.env.example`** ``` VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= ``` - [ ] **Step 5: Write the failing test** `src/lib/supabase.test.ts`: ```ts import { describe, it, expect, vi, beforeEach } from 'vitest' const createClientMock = vi.fn(() => ({ type: 'mock-supabase-client' })) vi.mock('@supabase/supabase-js', () => ({ createClient: createClientMock })) describe('supabase client setup', () => { beforeEach(() => { vi.resetModules() createClientMock.mockClear() }) it('creates the client with the URL and anon key read from Vite env vars', async () => { vi.stubEnv('VITE_SUPABASE_URL', 'https://test-project.supabase.co') vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key') const { supabase } = await import('./supabase') expect(createClientMock).toHaveBeenCalledWith('https://test-project.supabase.co', 'test-anon-key') expect(supabase).toEqual({ type: 'mock-supabase-client' }) }) }) ``` - [ ] **Step 6: Run the test to verify it fails** Run: `npx vitest run src/lib/supabase.test.ts` Expected: FAIL (module doesn't exist) - [ ] **Step 7: Implement `src/lib/supabase.ts`** ```ts import { createClient } from '@supabase/supabase-js' export const supabase = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY, ) ``` - [ ] **Step 8: Run the test to verify it passes** Run: `npx vitest run src/lib/supabase.test.ts` Expected: PASS - [ ] **Step 9: Verify the rest of the app still builds and tests pass** Run: `npx vitest run && npm run build` Expected: 52 tests passing (51 + this task's 1 new test), build succeeds. - [ ] **Step 10: Create your local `.env.local` (values filled in once Task 8 provides them; leave blank for now)** ```bash cp .env.example .env.local ``` - [ ] **Step 11: Commit** ```bash git add supabase/config.toml .gitignore package.json package-lock.json .env.example src/lib/supabase.ts src/lib/supabase.test.ts git commit -m "feat: add Supabase CLI scaffold and client SDK setup" ``` (`.env.local` is gitignored — verify with `git status` that it does not appear before committing.) --- ### Task 3: Database schema, indexes, and RLS policies **Files:** - Create: `supabase/migrations/20260808000001_initial_schema.sql` **Interfaces:** - Produces: the `products`, `services`, `schedule_template`, `schedule_exceptions`, `bookings`, `booked_slots`, `inquiries` tables and their RLS policies — the schema Task 4's tests verify and Task 7's seed script populates. - Uses a placeholder admin UUID, `00000000-0000-0000-0000-000000000001`, wherever the spec says "the admin's UID" — Task 8 (manual) provides the real one, which Task 9 substitutes at deploy time. Unlike the previous Firebase plan's string UID, this MUST be a syntactically valid UUID because Postgres's `auth.uid()` returns a `uuid`-typed value. - [ ] **Step 1: Write the migration** `supabase/migrations/20260808000001_initial_schema.sql`: ```sql -- Products, services, availability schedule, bookings, inquiries, and the -- public booked_slots mirror. See docs/superpowers/specs/2026-08-08-phase2-backend-foundation-supabase-design.md. create table public.products ( id text primary key, name text not null, category text not null check (category in ('hair-care', 'extensions', 'tools', 'accessories')), price numeric not null, image text not null, description text not null, in_stock boolean not null default true ); create table public.services ( id text primary key, name text not null, duration_minutes integer not null, price_from numeric not null, description text not null, image text not null ); create table public.schedule_template ( id text primary key default 'default', working_days integer[] not null, start_time time not null, end_time time not null, slot_minutes integer not null ); create table public.schedule_exceptions ( date date primary key, closed boolean not null, note text ); create table public.bookings ( id uuid primary key default gen_random_uuid(), service_id text not null references public.services(id), date date not null, time text not null, contact_name text not null, contact_phone text not null, contact_email text not null, contact_notes text not null default '', status text not null default 'confirmed' check (status in ('confirmed', 'cancelled')), created_at timestamptz not null default now() ); create index bookings_service_id_idx on public.bookings (service_id); create table public.booked_slots ( date date not null, time text not null, primary key (date, time) ); create table public.inquiries ( id uuid primary key default gen_random_uuid(), product_id text not null references public.products(id), contact_name text not null, contact_email text not null, contact_notes text not null default '', status text not null default 'new' check (status in ('new', 'read')), created_at timestamptz not null default now() ); create index inquiries_product_id_idx on public.inquiries (product_id); -- Row Level Security alter table public.products enable row level security; alter table public.services enable row level security; alter table public.schedule_template enable row level security; alter table public.schedule_exceptions enable row level security; alter table public.bookings enable row level security; alter table public.booked_slots enable row level security; alter table public.inquiries enable row level security; -- products: public read, admin write create policy "products_public_read" on public.products for select using (true); create policy "products_admin_write" on public.products for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); -- services: public read, admin write create policy "services_public_read" on public.services for select using (true); create policy "services_admin_write" on public.services for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); -- schedule_template: public read, admin write create policy "schedule_template_public_read" on public.schedule_template for select using (true); create policy "schedule_template_admin_write" on public.schedule_template for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); -- schedule_exceptions: public read, admin write create policy "schedule_exceptions_public_read" on public.schedule_exceptions for select using (true); create policy "schedule_exceptions_admin_write" on public.schedule_exceptions for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); -- bookings: anyone can insert (submit a booking); only admin can read/update/delete create policy "bookings_public_insert" on public.bookings for insert with check (true); create policy "bookings_admin_all" on public.bookings for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); -- booked_slots: public read; only admin policy exists for writes (in practice only -- the security-definer trigger in the next migration ever writes here) create policy "booked_slots_public_read" on public.booked_slots for select using (true); create policy "booked_slots_admin_write" on public.booked_slots for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); -- inquiries: anyone can insert; only admin can read/update/delete create policy "inquiries_public_insert" on public.inquiries for insert with check (true); create policy "inquiries_admin_all" on public.inquiries for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); ``` - [ ] **Step 2: Start the local Supabase stack and apply the migration** ```bash npx supabase start ``` The first run downloads several Docker images — this can take a few minutes. Once it finishes, it prints local URLs and keys (API URL, anon key, service_role key, Studio URL) — note the Studio URL, you'll use it to eyeball the schema in Step 3. - [ ] **Step 3: Verify the migration applied cleanly** ```bash npx supabase db reset ``` This drops and recreates the local database from `supabase/migrations/`, applying them in order. Expected: no errors, ends with a success message. Open the Studio URL from Step 2 in a browser and confirm all 7 tables exist under the `public` schema with RLS shown as enabled (a lock icon in Supabase Studio's table list). - [ ] **Step 4: Verify the existing app suite is unaffected** Run: `npx vitest run` Expected: still 52 tests passing (this task adds no Vitest test files — the migration is verified by the emulator apply in Step 3 and Task 4's pgTAP tests). - [ ] **Step 5: Commit** ```bash git add supabase/migrations/20260808000001_initial_schema.sql git commit -m "feat: add initial database schema, indexes, and RLS policies" ``` --- ### Task 4: RLS and trigger — booked_slots sync, plus pgTAP tests **Files:** - Create: `supabase/migrations/20260808000002_booked_slots_trigger.sql` - Create: `supabase/tests/database/rls.test.sql` **Interfaces:** - Consumes: the schema and RLS policies from Task 3. - Produces: `public.sync_booked_slot()` trigger function — no other code depends on its name directly, but Task 9's live smoke test relies on its behavior (a booking insert produces a `booked_slots` row). - [ ] **Step 1: Write the trigger migration** `supabase/migrations/20260808000002_booked_slots_trigger.sql`: ```sql -- Keeps the public, PII-free booked_slots table in sync whenever a booking -- is created. security definer + explicit search_path per Supabase's -- documented safe pattern for definer functions. create or replace function public.sync_booked_slot() returns trigger language plpgsql security definer set search_path = '' as $$ begin insert into public.booked_slots (date, time) values (new.date, new.time) on conflict do nothing; return new; end; $$; create trigger booking_created_sync_slot after insert on public.bookings for each row execute function public.sync_booked_slot(); ``` - [ ] **Step 2: Apply the migration locally** ```bash npx supabase db reset ``` Expected: applies cleanly (both migrations run in order). - [ ] **Step 3: Write the pgTAP test suite** `supabase/tests/database/rls.test.sql`: ```sql begin; select plan(11); -- Seed reference data as service_role (bypasses RLS) set local role service_role; insert into public.services (id, name, duration_minutes, price_from, description, image) values ('knotless', 'Knotless Braids', 240, 150, 'Tension-free braids.', 'styles/knotless.jpg'); insert into public.products (id, name, category, price, image, description, in_stock) values ('edge-control', 'Silk Edge Control', 'hair-care', 12, 'products/edge-control.jpg', 'Hold with shine.', true); reset role; -- Anyone can read services set local role anon; select isnt_empty( $$ select * from public.services $$, 'anon can read services' ); -- Anyone can read products select isnt_empty( $$ select * from public.products $$, 'anon can read products' ); -- Anon cannot write to services select throws_ok( $$ insert into public.services (id, name, duration_minutes, price_from, description, image) values ('hack', 'Hack', 1, 1, 'x', 'x') $$, '42501', null, 'anon cannot insert into services' ); -- Anyone can create a booking select lives_ok( $$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes) values ('knotless', '2026-09-15', '10:00 AM', 'Jane Doe', '555-0100', 'jane@example.com', '') $$, 'anon can create a booking' ); -- Anon cannot read bookings (PII protection) select is_empty( $$ select * from public.bookings $$, 'anon cannot read bookings' ); -- Anyone can create an inquiry select lives_ok( $$ insert into public.inquiries (product_id, contact_name, contact_email, contact_notes) values ('edge-control', 'Jane Doe', 'jane@example.com', 'Is this in stock?') $$, 'anon can create an inquiry' ); -- Anon cannot read inquiries (PII protection) select is_empty( $$ select * from public.inquiries $$, 'anon cannot read inquiries' ); -- Anyone can read booked_slots (no PII there) select isnt_empty( $$ select * from public.booked_slots $$, 'anon can read booked_slots (populated by the trigger from the booking above)' ); -- Anon cannot write booked_slots directly select throws_ok( $$ insert into public.booked_slots (date, time) values ('2026-09-16', '1:00 PM') $$, '42501', null, 'anon cannot insert into booked_slots directly' ); -- The admin (matching UID) can read bookings set local role authenticated; set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001'; select isnt_empty( $$ select * from public.bookings $$, 'admin can read bookings' ); -- The admin can write to services select lives_ok( $$ insert into public.services (id, name, duration_minutes, price_from, description, image) values ('cornrows', 'Cornrows', 120, 90, 'Classic style.', 'styles/cornrows.jpg') $$, 'admin can insert into services' ); select * from finish(); rollback; ``` - [ ] **Step 4: Run the pgTAP suite** ```bash npx supabase test db ``` Expected: all 11 assertions pass. If `request.jwt.claim.sub` doesn't make `auth.uid()` resolve as expected in your local Supabase CLI version, check `npx supabase --version` and consult `supabase test db --help` — the CLI's pgTAP harness sets up the `auth` schema's helper functions automatically, but the exact session-variable name for simulating a JWT claim has changed across CLI versions; adjust the `set local` statement to whatever your installed version's docs specify, and note the adjustment in your report. - [ ] **Step 5: Verify the existing app suite is unaffected** Run: `npx vitest run` Expected: still 52 tests (this task adds SQL tests run via a separate tool, not Vitest). - [ ] **Step 6: Commit** ```bash git add supabase/migrations/20260808000002_booked_slots_trigger.sql supabase/tests/database/rls.test.sql git commit -m "feat: add booked_slots sync trigger and pgTAP RLS/trigger tests" ``` --- ### Task 5: Edge Functions scaffold and notification email builders **Files:** - Create: `supabase/functions/_shared/email.ts` - Test: `supabase/functions/_shared/email.test.ts` - Create: `supabase/functions/deno.json` **Interfaces:** - Produces: `buildBookingNotificationEmail(input: BookingEmailInput): { subject: string; html: string }` and `buildInquiryNotificationEmail(input: InquiryEmailInput): { subject: string; html: string }` — pure functions, no Supabase/network dependency, imported by Task 6's actual Edge Functions. - [ ] **Step 1: Create a Deno config for the functions workspace** `supabase/functions/deno.json`: ```json { "tasks": { "test": "deno test --allow-none" } } ``` - [ ] **Step 2: Write the failing test** `supabase/functions/_shared/email.test.ts`: ```ts import { assertEquals, assertStringIncludes } from 'jsr:@std/assert' import { buildBookingNotificationEmail, buildInquiryNotificationEmail } from './email.ts' Deno.test('buildBookingNotificationEmail includes the service, date, time, and contact details', () => { const { subject, html } = buildBookingNotificationEmail({ serviceName: 'Knotless Braids', date: '2026-09-01', time: '10:00 AM', contact: { name: 'Jane Doe', phone: '555-0100', email: 'jane@example.com', notes: 'First time client' }, }) assertEquals(subject, 'New booking: Knotless Braids on 2026-09-01') assertStringIncludes(html, 'Jane Doe') assertStringIncludes(html, 'Knotless Braids') assertStringIncludes(html, '2026-09-01') assertStringIncludes(html, '10:00 AM') assertStringIncludes(html, '555-0100') assertStringIncludes(html, 'jane@example.com') assertStringIncludes(html, 'First time client') }) Deno.test('buildBookingNotificationEmail omits the notes paragraph when notes are empty', () => { const { html } = buildBookingNotificationEmail({ serviceName: 'Cornrows', date: '2026-09-02', time: '1:00 PM', contact: { name: 'Amy', phone: '555-0101', email: 'amy@example.com', notes: '' }, }) if (html.includes('Notes:')) throw new Error('expected no Notes: paragraph') }) Deno.test('buildInquiryNotificationEmail includes the product and contact details', () => { const { subject, html } = buildInquiryNotificationEmail({ productName: 'Silk Edge Control', contact: { name: 'Nadia', email: 'nadia@example.com', notes: 'Is this in stock?' }, }) assertEquals(subject, 'New inquiry: Silk Edge Control') assertStringIncludes(html, 'Nadia') assertStringIncludes(html, 'nadia@example.com') assertStringIncludes(html, 'Silk Edge Control') assertStringIncludes(html, 'Is this in stock?') }) Deno.test('buildInquiryNotificationEmail omits the notes paragraph when notes are empty', () => { const { html } = buildInquiryNotificationEmail({ productName: 'Braid Oil', contact: { name: 'Kim', email: 'kim@example.com', notes: '' }, }) if (html.includes('Notes:')) throw new Error('expected no Notes: paragraph') }) ``` - [ ] **Step 3: Run the test to verify it fails** Run: `cd supabase/functions && deno test _shared/email.test.ts` Expected: FAIL (module doesn't exist). If `deno` isn't installed, install it first — the Supabase CLI bundles Edge Function execution but standalone `deno test` needs the Deno runtime on your PATH; see https://deno.land for the one-line install script. - [ ] **Step 4: Implement `supabase/functions/_shared/email.ts`** ```ts export interface BookingEmailInput { serviceName: string date: string time: string contact: { name: string; phone: string; email: string; notes: string } } export function buildBookingNotificationEmail(input: BookingEmailInput): { subject: string; html: string } { const notesLine = input.contact.notes ? `
Notes: ${input.contact.notes}
` : '' return { subject: `New booking: ${input.serviceName} on ${input.date}`, html: `${input.contact.name} booked ${input.serviceName} on ${input.date} at ${input.time}.
` + `Phone: ${input.contact.phone}
Email: ${input.contact.email}
Notes: ${input.contact.notes}
` : '' return { subject: `New inquiry: ${input.productName}`, html: `${input.contact.name} (${input.contact.email}) asked about ${input.productName}.
${notesLine}`, } } ``` - [ ] **Step 5: Run the test to verify it passes** Run: `cd supabase/functions && deno test _shared/email.test.ts` Expected: PASS (5 tests) - [ ] **Step 6: Verify the root app suite is still unaffected** Run: `npx vitest run` Expected: still 52 tests — this task's files live under `supabase/functions/`, which the root `vite.config.ts` test config never scans by default since it's outside `src/` and not matched by Vitest's default include pattern... but verify this explicitly: run `npx vitest list 2>&1 | grep supabase` and confirm it returns nothing. If it does pick up `supabase/functions/**`, add `'supabase/functions/**'` to `vite.config.ts`'s `test.exclude` array and re-verify. - [ ] **Step 7: Commit** ```bash git add supabase/functions/deno.json supabase/functions/_shared/email.ts supabase/functions/_shared/email.test.ts git commit -m "feat: add Edge Functions workspace with tested email builders" ``` --- ### Task 6: `on-booking-created` and `on-inquiry-created` Edge Functions, wired to database triggers **Files:** - Create: `supabase/functions/on-booking-created/index.ts` - Create: `supabase/functions/on-inquiry-created/index.ts` - Create: `supabase/migrations/20260808000003_notify_triggers.sql` **Interfaces:** - Consumes: `buildBookingNotificationEmail`, `buildInquiryNotificationEmail` from Task 5's `supabase/functions/_shared/email.ts`. - Produces: two deployable Edge Functions, and the `pg_net`-based database triggers that invoke them on insert. - [ ] **Step 1: Implement `supabase/functions/on-booking-created/index.ts`** ```ts 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' } }) }) ``` - [ ] **Step 2: Implement `supabase/functions/on-inquiry-created/index.ts`** ```ts 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' } }) }) ``` - [ ] **Step 3: Serve the functions locally** In a separate terminal (leave it running): ```bash npx supabase functions serve --env-file .env.local ``` If `.env.local` doesn't have `RESEND_API_KEY` set yet (Task 8 hasn't happened), that's fine for now — the function will run and reach the point of calling Resend, which will fail without a real key. Steps 4-5 verify the function is *reachable and wired correctly*, not that a real email arrives (that's Task 9's live smoke test). - [ ] **Step 4: Write the database trigger migration that calls these functions via `pg_net`** `supabase/migrations/20260808000003_notify_triggers.sql`: ```sql 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. Default here is the local Supabase CLI functions -- gateway address — verify this resolves from inside the Postgres container in -- Step 5 below, and adjust if your local Docker networking differs (this is the -- one genuinely environment-dependent detail in this migration). alter database postgres set app.settings.edge_function_base_url = 'http://host.docker.internal:54321/functions/v1'; create or replace function public.notify_booking_created() returns trigger language plpgsql security definer set search_path = '' as $$ declare base_url text := current_setting('app.settings.edge_function_base_url', true); begin if base_url is not null then perform extensions.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)) ); end if; 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 := current_setting('app.settings.edge_function_base_url', true); begin if base_url is not null then perform extensions.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)) ); end if; return new; end; $$; create trigger inquiry_created_notify after insert on public.inquiries for each row execute function public.notify_inquiry_created(); ``` - [ ] **Step 5: Apply the migration and empirically verify the local networking address** ```bash npx supabase db reset ``` Then insert a test booking via `psql` or Supabase Studio's SQL editor and check whether the Edge Function (Step 3's `serve` terminal) logs an incoming request: ```sql insert into public.services (id, name, duration_minutes, price_from, description, image) values ('test-service', 'Test Service', 60, 50, 'test', 'test.jpg') on conflict (id) do nothing; insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes) values ('test-service', '2026-09-20', '10:00 AM', 'Trigger Test', '555-0199', 'trigger-test@example.com', ''); ``` **If the `supabase functions serve` terminal shows no incoming request within a few seconds**, the default `http://host.docker.internal:54321/functions/v1` address doesn't resolve from inside the local Postgres container in your Docker setup. Investigate the correct internal address (common alternatives: `http://kong:8000/functions/v1` if your Supabase CLI's Docker Compose network names the API gateway service `kong`; run `docker network inspect supabase_network_