46 KiB
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/, orsrc/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. bookingsandinquiriesrows contain client PII (name, phone, email) and must never be publicly readable — only the admin (a single fixed UUID) maySELECTthem. This is verified with pgTAP tests against a real local Postgres instance, not just asserted.booked_slotsis written only by asecurity definertrigger 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(removefirebasedependency, removetest:rulesandseedscripts) - Modify:
.gitignore(remove the now-unusedfunctions/lib/functions/node_modulesentries; 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
firebasedependency and the two Firebase-only scripts frompackage.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(viasupabase 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.tsexportssupabase— the only export later sub-projects import from this module — a configuredSupabaseClientinstance. -
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,inquiriestables 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'sauth.uid()returns auuid-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 abooked_slotsrow). -
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 }andbuildInquiryNotificationEmail(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,buildInquiryNotificationEmailfrom Task 5'ssupabase/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:
productsfromsrc/data/products.ts,servicesfromsrc/data/services.ts(both unchanged from Phase 1) — read-only. -
Produces: a
seednpm script that populatesproducts,services, andschedule_templatevia the Supabase JS client using the local service-role key, stripping eachimagefield'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
seedscript topackage.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-imagesstorage 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 realFROM_ADDRESS/MIRA_EMAILif 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_slotsfor2026-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/InquiryEmailInputand the twobuild*NotificationEmailfunctions 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.