12 KiB
Phase 2, Sub-project A — Backend Foundation Design (Supabase)
Context
This spec supersedes and replaces docs/superpowers/specs/2026-08-04-phase2-backend-foundation-design.md, which designed the same backend foundation on Firebase. That design was implemented and merged, but Firebase's Cloud Functions require the paid "Blaze" plan (a credit card on file) for any function that makes an outbound network call — needed here to send Mira email notifications. To avoid that requirement, the backend is being rebuilt on Supabase, whose free tier has no such restriction and additionally supports a fully offline local development/testing workflow via Docker, which Firebase's free tier does not for this use case.
As part of adopting this spec, the existing Firebase-specific code (firestore.rules, storage.rules, firebase.json, .firebaserc, functions/, src/lib/firebase.ts, firebase-tests/, the firebase npm dependency, and docs/superpowers/PHASE2A_REMAINING.md) is removed from the repository as part of this sub-project's implementation.
Phase 2 remains split into three sequential sub-projects, unchanged from the original decomposition:
- Sub-project A (this spec): backend foundation — database, auth, storage, notification functions. Seeded with the current mock data; the existing frontend is untouched and keeps working exactly as it does today.
- Sub-project B (future spec): admin panel — login, CRUD for products/services/availability, viewing bookings and inquiries.
- Sub-project C (future spec): public site goes live — Shop/Book/Rules read real data, bookings and inquiries actually persist through to the backend built here.
This spec only covers standing up the backend itself. No existing UI changes.
Tech Stack
- Supabase (Postgres database, Auth, Storage, Edge Functions) as the single backend platform.
- Supabase CLI + Docker for local development —
supabase startruns the entire stack locally (Postgres, Auth, Storage, Edge Functions, a Studio UI) with no live project or credentials required for development or testing. A live Supabase project is only needed for the final deploy step. - Resend — transactional email API used by the Edge Functions to notify Mira. Free tier (3,000 emails/month) is sufficient.
- pgTAP — Postgres-native test framework (via
supabase test db) for RLS policy and trigger tests, run against the local emulator. - Deno's built-in test runner (
deno test) for the Edge Functions' pure email-builder logic, matching the Edge Functions' native runtime. - No card/billing requirement anywhere in this stack for the scope of this project.
Data Model
Same PII-protection principle as the original design: the public site must be able to determine which appointment times are taken without ever being able to read who booked them.
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, -- Storage path, e.g. "products/edge-control.jpg"
description text not null,
in_stock boolean not null default true
)
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
)
schedule_template (
id text primary key default 'default',
working_days integer[] not null, -- 0=Sunday..6=Saturday, e.g. {2,3,4,5,6} for Tue-Sat
start_time time not null,
end_time time not null,
slot_minutes integer not null
)
schedule_exceptions (
date date primary key,
closed boolean not null,
note text
)
bookings ( -- ADMIN-ONLY READ — contains PII
id uuid primary key default gen_random_uuid(),
service_id text not null references 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()
)
booked_slots ( -- PUBLIC READ — no PII, kept in sync by a trigger
date date not null,
time text not null,
primary key (date, time)
)
inquiries ( -- ADMIN-ONLY READ — contains PII
id uuid primary key default gen_random_uuid(),
product_id text not null references 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()
)
Why booked_slots is separate from bookings: identical rationale to the original design — the public Book page needs to know which times are taken, but must never read who booked them. Here it's enforced even more strongly than in the Firebase version: booked_slots has no INSERT/UPDATE policy for any client role at all, so the only way it's ever written is by a trigger function running with the table owner's privileges (which bypasses RLS entirely) — there is no RLS policy combination a client could exploit to write it directly, unlike Firestore where "admin-only write" was a rule that could theoretically be misconfigured.
Shape validation improvement over the Firebase version: the original design needed hand-written Firestore rule expressions (hasAll(...), is string, regex matches(...)) to validate booking/inquiry shape, added as a follow-up fix after the final review caught the gap. Postgres does this natively via column constraints (not null, check (...), foreign keys) as part of the schema itself — there's no equivalent gap to catch here, since malformed inserts are rejected by the database before any RLS policy is even evaluated.
Row Level Security (RLS)
RLS is enabled on every table (alter table ... enable row level security).
products,services,schedule_template,schedule_exceptions,booked_slots:SELECTallowed for everyone (using (true));INSERT/UPDATE/DELETErestricted to the admin (auth.uid() = '<Mira's fixed UID>', a single hardcoded UID constant — no roles system needed for one admin user).bookings,inquiries:INSERTallowed for everyone (so the public booking/inquiry forms can submit without being signed in);SELECT/UPDATE/DELETErestricted to the admin.booked_slots: noINSERT/UPDATEpolicy exists for any role except the admin policy above (which is moot in practice since only the trigger function, running with elevated privileges, ever writes here) — see the Data Model section for why this is stronger than a simple "admin-only" policy would suggest.
Database Trigger
AFTER INSERT ON bookings FOR EACH ROW EXECUTE FUNCTION sync_booked_slot():
create function sync_booked_slot() returns trigger as $$
begin
insert into booked_slots (date, time) values (new.date, new.time)
on conflict do nothing;
return new;
end;
$$ language plpgsql security definer;
security definer makes this run with the function owner's privileges (bypassing the caller's RLS), which is what allows an anonymous client's booking INSERT to still result in a booked_slots row despite that table having no client-facing write policy. Pure SQL, atomic (on conflict do nothing handles the case where a slot is somehow inserted twice), no network round-trip — simpler and faster than the Firebase version's Cloud Function equivalent.
Edge Functions & Notifications
supabase/functions/on-booking-created/index.ts— invoked by a Database Webhook (a Supabase-native feature: a trigger that fires an authenticated HTTP call to an Edge Function on table changes) configured onbookingsinsert. Looks up the service name viaservices(so the email reads "Knotless Braids", not the rawservice_id— an improvement over the Firebase version, which emailed the raw slug), builds the notification with a purebuildBookingNotificationEmail()helper, sends it via Resend.supabase/functions/on-inquiry-created/index.ts— same pattern, triggered oninquiriesinsert, looks up the product name, sends via Resend.- Both pure email-builder functions live in
supabase/functions/_shared/email.ts, imported by both function handlers — no duplication. - The Resend API key is stored as a Supabase Edge Function secret (
supabase secrets set RESEND_API_KEY=...), never committed.
Storage
- A single
salon-imagesbucket with path prefixesproducts/,styles/,about/mirroring the currentpublic/structure. - Storage policies: public
SELECT(read) on all objects in the bucket;INSERT/UPDATE/DELETErestricted to the admin UID, mirroring the Firestore-table access model.
Client Integration
- Add the
@supabase/supabase-jsnpm package to the existing Vite app. - New
src/lib/supabase.tsinitializes the Supabase client from environment variables (VITE_SUPABASE_URL,VITE_SUPABASE_ANON_KEY) and exports a single configuredsupabaseclient instance for later sub-projects to import. - Config values come from
.env.local(gitignored);.env.exampledocuments the required variable names.
Seeding
- A seed script (SQL migration or a small script using the Supabase client with the service-role key) inserts the current mock data: all 10 products, all 4 services, and a default
schedule_templaterow matching the existing mock availability pattern (Tue-Sat, working hours, 2-hour slots). - Run against the local emulator during development/testing; run again against the real production project at final deploy.
Local Development & Testing
supabase startboots the entire stack in Docker — Postgres, Auth, Storage, Edge Functions runtime, and a local Studio UI — with no live Supabase project or credentials needed. This covers effectively all of this sub-project's development and testing.- RLS + trigger tests: written in pgTAP, run via
supabase test dbagainst the local Postgres instance. Tests assert, using different Postgres roles (anon, an authenticated non-admin, the authenticated admin), exactly what the spec's access model claims: anonymous/non-admin cannot readbookings/inquiriesor write toproducts/services/booked_slots; anonymous can insert intobookings/inquiriesand read the public tables; the admin can do everything; a booking insert produces a matchingbooked_slotsrow via the trigger. - Edge Function tests:
deno testagainstsupabase/functions/_shared/email.ts's pure functions — same coverage shape as the Firebase version's Vitest tests (subject/body content, notes-omission branch), just using Deno's runtime and test runner since that's what Edge Functions actually run on. - A live Supabase project (free tier, no card) is only required for the final deploy task: pushing migrations, deploying Edge Functions, setting the Resend secret, seeding production data, and a live end-to-end smoke test (real booking → real email).
Out of Scope (this sub-project)
- Any change to the existing public site UI or
src/data/*.ts— those stay exactly as they are until sub-project C. - The admin panel UI itself (login screen, product/service/availability management screens, bookings/inquiries dashboard) — sub-project B.
- Wiring the public Book/Shop pages to actually read from Supabase or write real bookings/inquiries — sub-project C.
- Any booking-cancellation flow beyond the
statuscolumn existing onbookings— cancellation UI is an admin-panel concern for sub-project B.