Files
braidsbymira/docs/superpowers/plans/2026-08-08-phase2a-backend-foundation-supabase.md

46 KiB
Raw Blame History

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

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:

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
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

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
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:

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
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)
cp .env.example .env.local
  • Step 11: Commit
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:

-- 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
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
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
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:

-- 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
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:

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
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
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:

{
  "tasks": {
    "test": "deno test --allow-none"
  }
}
  • Step 2: Write the failing test

supabase/functions/_shared/email.test.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
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 ? `<p>Notes: ${input.contact.notes}</p>` : ''
  return {
    subject: `New booking: ${input.serviceName} on ${input.date}`,
    html: `<p><strong>${input.contact.name}</strong> booked <strong>${input.serviceName}</strong> on ${input.date} at ${input.time}.</p>` +
      `<p>Phone: ${input.contact.phone}<br/>Email: ${input.contact.email}</p>${notesLine}`,
  }
}

export interface InquiryEmailInput {
  productName: string
  contact: { name: string; email: string; notes: string }
}

export function buildInquiryNotificationEmail(input: InquiryEmailInput): { subject: string; html: string } {
  const notesLine = input.contact.notes ? `<p>Notes: ${input.contact.notes}</p>` : ''
  return {
    subject: `New inquiry: ${input.productName}`,
    html: `<p><strong>${input.contact.name}</strong> (${input.contact.email}) asked about <strong>${input.productName}</strong>.</p>${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
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

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
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):

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:

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
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:

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_<project> — the project name comes from supabase/config.toml — to see container names, or docker exec into the db container and try curl against candidate URLs). Once you find the working address, update the alter database ... set app.settings.edge_function_base_url = '...' line in this migration file to the correct value, then npx supabase db reset again and re-verify. Document what you found and why in your report — this is expected to require empirical adjustment, not a sign you did something wrong.

  • Step 6: Clean up the test rows
delete from public.bookings where contact_email = 'trigger-test@example.com';
delete from public.booked_slots where date = '2026-09-20';
delete from public.services where id = 'test-service';
  • Step 7: Re-run the pgTAP suite and root app suite to confirm nothing regressed

Run: npx supabase test db && npx vitest run Expected: pgTAP 11/11 pass, root suite 52/52 pass.

  • Step 8: Commit
git add supabase/functions/on-booking-created/index.ts supabase/functions/on-inquiry-created/index.ts \
  supabase/migrations/20260808000003_notify_triggers.sql
git commit -m "feat: add on-booking-created and on-inquiry-created Edge Functions with DB-trigger wiring"

(You can stop the supabase functions serve terminal now — Task 9 will restart it or deploy the real thing.)


Task 7: Seed script

Files:

  • Create: scripts/seed.ts
  • Modify: package.json (new script)

Interfaces:

  • Consumes: products from src/data/products.ts, services from src/data/services.ts (both unchanged from Phase 1) — read-only.

  • Produces: a seed npm script that populates products, services, and schedule_template via the Supabase JS client using the local service-role key, stripping each image field's leading /.

  • Step 1: Write scripts/seed.ts

import { createClient } from '@supabase/supabase-js'
import { products } from '../src/data/products.js'
import { services } from '../src/data/services.js'

const supabaseUrl = process.env.SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY

if (!supabaseUrl || !serviceRoleKey) {
  console.error('Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables before running this script.')
  process.exit(1)
}

const supabase = createClient(supabaseUrl, serviceRoleKey)

function toStoragePath(image: string): string {
  return image.replace(/^\//, '')
}

async function seed() {
  const productRows = products.map(({ id, image, ...rest }) => ({ id, image: toStoragePath(image), ...rest }))
  const { error: productsError } = await supabase.from('products').upsert(productRows)
  if (productsError) throw productsError

  const serviceRows = services.map(({ id, image, ...rest }) => ({ id, image: toStoragePath(image), ...rest }))
  const { error: servicesError } = await supabase.from('services').upsert(serviceRows)
  if (servicesError) throw servicesError

  const { error: scheduleError } = await supabase.from('schedule_template').upsert({
    id: 'default',
    working_days: [2, 3, 4, 5, 6],
    start_time: '09:00',
    end_time: '18:00',
    slot_minutes: 120,
  })
  if (scheduleError) throw scheduleError

  console.log(`Seeded ${products.length} products, ${services.length} services, and the default schedule.`)
}

seed().catch((err) => {
  console.error(err)
  process.exit(1)
})
  • Step 2: Install tsx
npm install -D tsx
  • Step 3: Add the seed script to package.json
"seed": "tsx scripts/seed.ts"
  • Step 4: Dry-run the seed script against the local Supabase instance
npx supabase status

Note the "API URL" and "service_role key" values printed. Then run:

SUPABASE_URL=<API URL from above> SUPABASE_SERVICE_ROLE_KEY=<service_role key from above> npm run seed

Expected output: Seeded 10 products, 4 services, and the default schedule. with no errors.

  • Step 5: Verify in Supabase Studio

Open the Studio URL from supabase status, browse to the products, services, and schedule_template tables, and confirm the rows are present with the expected data.

  • Step 6: Verify the rest of the suite is unaffected

Run: npx vitest run Expected: still 52 tests (this task adds no Vitest test files — the seed script is verified by Steps 4-5's manual dry run, since its job is a database side effect).

  • Step 7: Commit
git add package.json package-lock.json scripts/seed.ts
git commit -m "feat: add Supabase seed script for products, services, and default schedule"

Task 8 (MANUAL — human only, do not dispatch to a subagent): Live Supabase project and Resend setup

This task cannot be automated — it requires you personally to create accounts and click through dashboard UI. No billing card is required anywhere in this task. Complete it yourself before Task 9; the values you gather here are needed as literal substitutions in Task 9.

  • Step 1: Create a Supabase account and project

Go to https://supabase.com, sign up (no card required), create a new project. Note the Project Reference (visible in the project URL and in Project Settings → General) and the Project API URL and anon public key (Project Settings → API).

  • Step 2: Enable email/password auth and create Mira's admin user

In the Supabase dashboard, go to Authentication → Providers, confirm "Email" is enabled (it is by default). Go to Authentication → Users → Add user, create one user with Mira's real email and a password she'll use later (the admin panel login screen comes in Sub-project B). Copy the created user's UID (a UUID) — this is the real value that replaces 00000000-0000-0000-0000-000000000001 everywhere it appears.

  • Step 3: Create the salon-images storage bucket

In the dashboard, go to Storage → New bucket, name it salon-images, and leave it private for now (policies are added in a later task when the admin panel needs it — Sub-project A doesn't need Storage wired up yet, this just reserves the bucket name so Sub-project B can build on it).

  • Step 4: Create a Resend account and API key

Go to https://resend.com, sign up, create an API key. Either verify your own sending domain or use Resend's shared test domain (onboarding@resend.dev) while testing — note which one you're using, it affects FROM_ADDRESS in Task 6's Edge Functions.

  • Step 5: Link the local Supabase CLI to your project
npx supabase login
npx supabase link --project-ref <your project reference from Step 1>

login opens a browser for interactive authentication — this is the one step in this whole plan that must be done by a human. link associates your local supabase/ directory with the real project so supabase db push/supabase functions deploy know where to send things.

  • Step 6: Record the values you gathered

You now have: Project Reference, Project API URL, anon public key, Mira's admin UID, the Resend API key, and the Resend from address. Hand all of these to whoever executes Task 9 — they're substituted as literal values, not left as placeholders.


Task 9: Deploy and live verification

Files:

  • Modify: supabase/migrations/20260808000001_initial_schema.sql, supabase/migrations/20260808000003_notify_triggers.sql (substitute the real admin UID and production functions URL)
  • Modify: supabase/functions/on-booking-created/index.ts, supabase/functions/on-inquiry-created/index.ts (substitute the real FROM_ADDRESS/MIRA_EMAIL if they differ from the placeholders)
  • Modify: supabase/tests/database/rls.test.sql (substitute the real admin UID so the test still matches the deployed policies)
  • Modify: .env.local (real Supabase URL/anon key — not committed)

Interfaces:

  • Consumes: everything from Tasks 1–8. Requires the linked, authenticated Supabase CLI session from Task 8.

  • Step 1: Substitute the real admin UID everywhere the placeholder appears

grep -rl '00000000-0000-0000-0000-000000000001' supabase/

Replace every occurrence in the listed files with the real UID from Task 8, Step 2.

  • Step 2: Update the production Edge Functions base URL

In supabase/migrations/20260808000003_notify_triggers.sql, change the alter database postgres set app.settings.edge_function_base_url = '...' line to point at your real project's functions URL: https://<project-ref>.supabase.co/functions/v1 (using the Project Reference from Task 8, Step 1).

  • Step 3: Update the notification email addresses if needed

If Mira's real notification inbox or Resend-verified sending address differ from the placeholders (bookings@braidsbymira.com, hello@braidsbymira.com), update FROM_ADDRESS/MIRA_EMAIL in both supabase/functions/on-booking-created/index.ts and supabase/functions/on-inquiry-created/index.ts.

  • Step 4: Push the migrations to the real project
npx supabase db push

Expected: applies all 3 migrations to the live database, prints success.

  • Step 5: Set the Resend secret and deploy the Edge Functions
npx supabase secrets set RESEND_API_KEY=<your real Resend API key>
npx supabase functions deploy on-booking-created
npx supabase functions deploy on-inquiry-created
  • Step 6: Fill in your real .env.local
VITE_SUPABASE_URL=<Project API URL from Task 8>
VITE_SUPABASE_ANON_KEY=<anon public key from Task 8>
  • Step 7: Seed the real production database
SUPABASE_URL=<Project API URL> SUPABASE_SERVICE_ROLE_KEY=<service_role key, from Project Settings → API> npm run seed

Expected: Seeded 10 products, 4 services, and the default schedule. Confirm in the Supabase dashboard's Table Editor.

  • Step 8: Live smoke test — create a real booking and confirm the trigger + function fire

In the Supabase dashboard's SQL Editor, run:

insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes)
values ('knotless', '2026-09-15', '10:00 AM', 'Test Client', '555-0100', 'test@example.com', 'Smoke test');

Expected, within a few seconds:

  • A new row appears in booked_slots for 2026-09-15 / 10:00 AM.

  • An email arrives at Mira's real inbox with subject "New booking: Knotless Braids on 2026-09-15" (note: the real service name, not the slug — confirms the name lookup in the Edge Function worked).

  • Step 9: Live smoke test — create a real inquiry

insert into public.inquiries (product_id, contact_name, contact_email, contact_notes)
values ('edge-control', 'Test Client', 'test@example.com', 'Smoke test inquiry');

Expected: an email arrives with subject "New inquiry: Silk Edge Control".

  • Step 10: Clean up the smoke-test rows
delete from public.bookings where contact_email = 'test@example.com';
delete from public.inquiries where contact_email = 'test@example.com';
delete from public.booked_slots where date = '2026-09-15';
  • Step 11: Re-run the local pgTAP suite with the updated admin UID
npx supabase db reset
npx supabase test db

Expected: still 11/11 passing locally, confirming the substituted UID didn't break anything.

  • Step 12: Final confirmation the frontend is completely unaffected

Run: npx vitest run && npm run build && npm run lint Expected: all pass, unchanged from before this entire plan.

  • Step 13: Commit the substitutions
git add supabase/migrations supabase/functions supabase/tests
git commit -m "chore: substitute real admin UID, production functions URL, and notification addresses"

(.env.local stays gitignored, never committed.)


Self-Review Notes

  • Spec coverage: tech stack (Task 2, 5), data model + RLS + PII separation (Task 3, verified by Task 4), booked_slots trigger (Task 4), Edge Functions + database-trigger notification wiring (Task 5, 6), client integration (Task 2), seeding (Task 7), Firebase removal (Task 1), live deploy/verification (Task 8, 9). All spec sections are covered.
  • Type consistency: BookingEmailInput/InquiryEmailInput and the two build*NotificationEmail functions are defined once in Task 5 and consumed unchanged in Task 6. The placeholder admin UUID (00000000-0000-0000-0000-000000000001) is used identically across Tasks 3, 4, and 6, and substituted consistently across the same file set in Task 9.
  • No placeholders: every step has literal file contents and exact commands, except the intentional UID/URL/email substitutions Task 8 explicitly produces as real values for the controller to carry into Task 9 — flagged clearly, not vague TODOs. The one genuinely environment-dependent detail (the local Docker-internal functions URL in Task 6) is called out explicitly as requiring empirical verification, with concrete troubleshooting steps, rather than presented as a guaranteed fact.