1161 lines
46 KiB
Markdown
1161 lines
46 KiB
Markdown
# 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.
|