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

1161 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ? `<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**
```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_<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**
```sql
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**
```bash
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`**
```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`**
```bash
npm install -D tsx
```
- [ ] **Step 3: Add the `seed` script to `package.json`**
```json
"seed": "tsx scripts/seed.ts"
```
- [ ] **Step 4: Dry-run the seed script against the local Supabase instance**
```bash
npx supabase status
```
Note the "API URL" and "service_role key" values printed. Then run:
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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:
```sql
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**
```sql
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**
```sql
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**
```bash
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**
```bash
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.