diff --git a/docs/superpowers/plans/2026-08-04-phase2a-backend-foundation.md b/docs/superpowers/plans/2026-08-04-phase2a-backend-foundation.md new file mode 100644 index 0000000..a00bf22 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-phase2a-backend-foundation.md @@ -0,0 +1,995 @@ +# Phase 2A: Backend Foundation 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 1 is a manual, human-only task — do not dispatch it to a subagent.** It requires interactive account/billing setup no agent can complete. Every later task depends on values gathered in Task 1 (the Firebase project ID and Mira's admin UID); the controller must carry those values into each later task's dispatch context since the task briefs use placeholders for them. + +**Goal:** Stand up the Firebase backend (Firestore, Auth, Storage, Cloud Functions) for Braids by Mira — database, security rules, email-notification functions, and seed data — without touching the existing frontend, which keeps working exactly as it does today. + +**Architecture:** Firestore holds products/services/schedule as public-read/admin-write collections, and bookings/inquiries as public-create/admin-read-only collections (to keep client PII out of public reach). A small `bookedSlots` collection mirrors booked times without PII, kept in sync by a Firestore-triggered Cloud Function. A second Cloud Function emails Mira via Resend on every new booking/inquiry. + +**Tech Stack:** Firebase (Firestore, Auth, Storage, Cloud Functions v2), Resend (transactional email), `firebase` JS SDK (client), `firebase-admin` + `firebase-functions` (Cloud Functions), `@firebase/rules-unit-testing` (rules tests), Vitest. + +## Global Constraints + +- No existing frontend file under `src/pages/`, `src/components/`, or `src/data/` is modified in this plan — Sub-project A only adds new 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` documents contain client PII (name, phone, email) and must never be publicly readable — only Mira's admin UID may read them. This is verified with rules tests, not just asserted. +- `bookedSlots` is written only by the Cloud Function (via `firebase-admin`, which bypasses security rules) — never by client code — so it can safely be public-read without exposing PII. +- Firebase config (API keys, project ID) and the Resend API key are never committed — they live in `.env.local` (gitignored) and Cloud Functions secrets respectively. +- Palette/typography/existing site behavior: untouched by this plan (it's backend-only). + +--- + +### Task 1 (MANUAL — human only, do not dispatch to a subagent): Firebase & Resend account setup + +This task cannot be automated — it requires you personally to create accounts, click through console UI, and attach a billing method. Complete it yourself before any other task starts; the values you gather here (Firebase project ID, Mira's admin UID) are needed as literal substitutions in Tasks 3–8. + +- [ ] **Step 1: Create a Firebase project** + +Go to https://console.firebase.google.com, create a new project (e.g. "braids-by-mira"). Note the **Project ID** shown in the project settings — you'll need it verbatim later. + +- [ ] **Step 2: Upgrade to the Blaze (pay-as-you-go) plan** + +In the Firebase console, go to Project Settings → Usage and billing → Modify plan → Blaze. This requires a payment method. Cloud Functions making outbound calls (to Resend) do not work on the free Spark plan. Blaze has a generous free tier for a site this size — you're very unlikely to be charged, but billing must be attached. + +- [ ] **Step 3: Enable Firestore, Authentication, Storage, and Functions** + +In the console sidebar: Build → Firestore Database → Create database (production mode, pick a region). Build → Authentication → Get started → enable the "Email/Password" sign-in provider. Build → Storage → Get started. Build → Functions (this activates automatically once Blaze is enabled). + +- [ ] **Step 4: Create Mira's admin user** + +In Authentication → Users → Add user, create one user with Mira's real email and a password she'll use to log in later (the admin panel login screen comes in Sub-project B — for now this just needs to exist). After creating it, click into the user and copy its **User UID** — a long alphanumeric string. Save this value; it's needed verbatim in Tasks 3, 6, and 7. + +- [ ] **Step 5: Register a web app and get the client config** + +In Project Settings → General → Your apps → Add app → Web. Register it (any nickname). Firebase shows a config object with `apiKey`, `authDomain`, `projectId`, `storageBucket`, `messagingSenderId`, `appId`. Save all six values. + +- [ ] **Step 6: Create a Resend account and API key** + +Go to https://resend.com, sign up, create an API key (Dashboard → API Keys → Create API Key). For sending, either verify your own domain (Dashboard → Domains) or use Resend's shared test domain (`onboarding@resend.dev`) as the `from` address while testing — note which one you're using, since it affects the Cloud Function's `from` address in Task 6. Save the API key. + +- [ ] **Step 7: Install the Firebase CLI and authenticate** + +The Firebase CLI needs an interactive browser login, which an agent cannot complete. Run this yourself in your own terminal (or type `! npx firebase-tools login` in a Claude Code session to run it in your authenticated shell): + +```bash +npx firebase-tools login +``` + +Confirm success with: + +```bash +npx firebase-tools projects:list +``` + +Your project from Step 1 should appear in the list. + +- [ ] **Step 8: Record the values you gathered** + +You now have: **Firebase Project ID**, **Mira's admin UID**, the **6 web app config values**, the **Resend API key**, and the **Resend `from` address** you'll use. Hand the Project ID and admin UID to whoever executes Task 3 onward (they're substituted as literal values, not left as placeholders, in those tasks' code). Keep the config values and API key handy for Task 2 and Task 6. + +--- + +### Task 2: Firebase client SDK setup + +**Files:** +- Modify: `package.json` (new dependency) +- Modify: `.gitignore` +- Create: `.env.example` +- Create: `src/lib/firebase.ts` +- Test: `src/lib/firebase.test.ts` + +**Interfaces:** +- Produces: `src/lib/firebase.ts` exports `app` (the initialized `FirebaseApp`), `db` (Firestore instance), `auth` (Auth instance), `storage` (Storage instance) — these are the only exports later sub-projects import from this module. + +- [ ] **Step 1: Install the Firebase SDK** + +```bash +npm install firebase +``` + +- [ ] **Step 2: Add `.env.local` and `.env.*.local` to `.gitignore`** + +Add this block to `.gitignore` (Vite's convention for local env files): + +``` +# Environment variables +.env.local +.env.*.local +``` + +- [ ] **Step 3: Create `.env.example`** + +`.env.example`: + +``` +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_STORAGE_BUCKET= +VITE_FIREBASE_MESSAGING_SENDER_ID= +VITE_FIREBASE_APP_ID= +``` + +- [ ] **Step 4: Write the failing test** + +`src/lib/firebase.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const initializeAppMock = vi.fn(() => ({ name: 'mock-app' })) +const getFirestoreMock = vi.fn(() => ({ type: 'mock-firestore' })) +const getAuthMock = vi.fn(() => ({ type: 'mock-auth' })) +const getStorageMock = vi.fn(() => ({ type: 'mock-storage' })) + +vi.mock('firebase/app', () => ({ initializeApp: initializeAppMock })) +vi.mock('firebase/firestore', () => ({ getFirestore: getFirestoreMock })) +vi.mock('firebase/auth', () => ({ getAuth: getAuthMock })) +vi.mock('firebase/storage', () => ({ getStorage: getStorageMock })) + +describe('firebase client setup', () => { + beforeEach(() => { + vi.resetModules() + initializeAppMock.mockClear() + }) + + it('initializes the app with config read from Vite env vars and exports db/auth/storage', async () => { + vi.stubEnv('VITE_FIREBASE_API_KEY', 'test-api-key') + vi.stubEnv('VITE_FIREBASE_AUTH_DOMAIN', 'test.firebaseapp.com') + vi.stubEnv('VITE_FIREBASE_PROJECT_ID', 'test-project') + vi.stubEnv('VITE_FIREBASE_STORAGE_BUCKET', 'test-project.appspot.com') + vi.stubEnv('VITE_FIREBASE_MESSAGING_SENDER_ID', '123') + vi.stubEnv('VITE_FIREBASE_APP_ID', 'app-id') + + const { app, db, auth, storage } = await import('./firebase') + + expect(initializeAppMock).toHaveBeenCalledWith({ + apiKey: 'test-api-key', + authDomain: 'test.firebaseapp.com', + projectId: 'test-project', + storageBucket: 'test-project.appspot.com', + messagingSenderId: '123', + appId: 'app-id', + }) + expect(app).toEqual({ name: 'mock-app' }) + expect(db).toEqual({ type: 'mock-firestore' }) + expect(auth).toEqual({ type: 'mock-auth' }) + expect(storage).toEqual({ type: 'mock-storage' }) + }) +}) +``` + +- [ ] **Step 5: Run the test to verify it fails** + +Run: `npx vitest run src/lib/firebase.test.ts` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 6: Implement `src/lib/firebase.ts`** + +```ts +import { initializeApp } from 'firebase/app' +import { getFirestore } from 'firebase/firestore' +import { getAuth } from 'firebase/auth' +import { getStorage } from 'firebase/storage' + +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, +} + +export const app = initializeApp(firebaseConfig) +export const db = getFirestore(app) +export const auth = getAuth(app) +export const storage = getStorage(app) +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `npx vitest run src/lib/firebase.test.ts` +Expected: PASS + +- [ ] **Step 8: Verify the rest of the app still builds and tests pass** + +Run: `npx vitest run && npm run build` +Expected: all existing tests still pass (this task adds one new test file and doesn't touch any existing page/component), build succeeds. + +- [ ] **Step 9: Create your local `.env.local` from the values gathered in Task 1** + +Copy `.env.example` to `.env.local` and fill in the 6 values from Task 1, Step 5. This file is gitignored and never committed. + +```bash +cp .env.example .env.local +``` + +- [ ] **Step 10: Commit** + +```bash +git add package.json package-lock.json .gitignore .env.example src/lib/firebase.ts src/lib/firebase.test.ts +git commit -m "feat: add Firebase client SDK setup" +``` + +(`.env.local` is gitignored and must NOT be committed — verify with `git status` that it doesn't appear before committing.) + +--- + +### Task 3: Firebase project config and security rules + +**Files:** +- Create: `firebase.json` +- Create: `.firebaserc` +- Create: `firestore.indexes.json` +- Create: `firestore.rules` +- Create: `storage.rules` + +**Interfaces:** +- Consumes: the **Firebase Project ID** and **Mira's admin UID** gathered in Task 1 — the controller dispatching this task substitutes both as literal values; do not leave placeholder text in the committed files. +- Produces: the security rule structure Task 4's tests verify against, and that `firebase deploy` (Task 8) deploys. + +- [ ] **Step 1: Create `.firebaserc`** + +Replace `YOUR_PROJECT_ID` with the real Firebase Project ID from Task 1: + +```json +{ + "projects": { + "default": "YOUR_PROJECT_ID" + } +} +``` + +- [ ] **Step 2: Create `firebase.json`** + +```json +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"] + } + ], + "emulators": { + "auth": { "port": 9099 }, + "functions": { "port": 5001 }, + "firestore": { "port": 8080 }, + "storage": { "port": 9199 }, + "ui": { "enabled": true } + } +} +``` + +- [ ] **Step 3: Create `firestore.indexes.json`** + +```json +{ + "indexes": [], + "fieldOverrides": [] +} +``` + +- [ ] **Step 4: Create `firestore.rules`** + +Replace `ADMIN_UID` with the real UID from Task 1, Step 4: + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + function isAdmin() { + return request.auth != null && request.auth.uid == "ADMIN_UID"; + } + + match /products/{productId} { + allow read: if true; + allow write: if isAdmin(); + } + + match /services/{serviceId} { + allow read: if true; + allow write: if isAdmin(); + } + + match /scheduleTemplate/{docId} { + allow read: if true; + allow write: if isAdmin(); + } + + match /scheduleExceptions/{date} { + allow read: if true; + allow write: if isAdmin(); + } + + match /bookedSlots/{date} { + allow read: if true; + allow write: if isAdmin(); + } + + match /bookings/{bookingId} { + allow create: if true; + allow read, update, delete: if isAdmin(); + } + + match /inquiries/{inquiryId} { + allow create: if true; + allow read, update, delete: if isAdmin(); + } + } +} +``` + +- [ ] **Step 5: Create `storage.rules`** + +Replace `ADMIN_UID` with the same real UID: + +``` +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + function isAdmin() { + return request.auth != null && request.auth.uid == "ADMIN_UID"; + } + + match /products/{fileName} { + allow read: if true; + allow write: if isAdmin(); + } + + match /styles/{fileName} { + allow read: if true; + allow write: if isAdmin(); + } + + match /about/{fileName} { + allow read: if true; + allow write: if isAdmin(); + } + } +} +``` + +- [ ] **Step 6: Verify the JSON files parse correctly** + +Run: `node -e "JSON.parse(require('fs').readFileSync('firebase.json', 'utf8')); JSON.parse(require('fs').readFileSync('.firebaserc', 'utf8')); JSON.parse(require('fs').readFileSync('firestore.indexes.json', 'utf8')); console.log('OK')"` +Expected: prints `OK` with no errors. + +- [ ] **Step 7: Commit** + +```bash +git add firebase.json .firebaserc firestore.indexes.json firestore.rules storage.rules +git commit -m "feat: add Firebase project config and security rules" +``` + +--- + +### Task 4: Security rules tests + +**Files:** +- Create: `firebase-tests/firestore.rules.test.ts` +- Create: `vitest.rules.config.ts` +- Modify: `package.json` (new devDependencies, new script) + +**Interfaces:** +- Consumes: `firestore.rules`, `storage.rules` from Task 3 — read from disk at test time, with `ADMIN_UID` (the real UID from Task 1) used directly since it's already literal in the committed rules files. +- Produces: a `test:rules` npm script that runs these tests against the Firestore/Storage emulators — this is the actual verification that the Global Constraint ("bookings/inquiries are never publicly readable") holds, not just a written assertion in the spec. + +- [ ] **Step 1: Install test dependencies** + +```bash +npm install -D @firebase/rules-unit-testing firebase-tools +``` + +- [ ] **Step 2: Create `vitest.rules.config.ts`** + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['firebase-tests/**/*.test.ts'], + testTimeout: 20000, + }, +}) +``` + +- [ ] **Step 3: Write the rules tests** + +`firebase-tests/firestore.rules.test.ts` — replace `ADMIN_UID` in the two `authenticatedContext` calls with the real admin UID from Task 1 (must match what's literally in `firestore.rules`): + +```ts +import { readFileSync } from 'node:fs' +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { + initializeTestEnvironment, + assertSucceeds, + assertFails, + type RulesTestEnvironment, +} from '@firebase/rules-unit-testing' +import { doc, getDoc, setDoc, addDoc, collection } from 'firebase/firestore' + +const ADMIN_UID = 'ADMIN_UID' + +let testEnv: RulesTestEnvironment + +beforeAll(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'demo-braids-by-mira', + firestore: { + rules: readFileSync('firestore.rules', 'utf8'), + }, + }) +}) + +afterAll(async () => { + await testEnv.cleanup() +}) + +beforeEach(async () => { + await testEnv.clearFirestore() +}) + +describe('Firestore security rules', () => { + it('allows anyone to read products', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'products/edge-control'), { name: 'Edge Control' }) + }) + const unauthed = testEnv.unauthenticatedContext() + await assertSucceeds(getDoc(doc(unauthed.firestore(), 'products/edge-control'))) + }) + + it('blocks a non-admin from writing to products', async () => { + const unauthed = testEnv.unauthenticatedContext() + await assertFails(setDoc(doc(unauthed.firestore(), 'products/hacked'), { name: 'Hack' })) + }) + + it('allows the admin to write to products', async () => { + const admin = testEnv.authenticatedContext(ADMIN_UID) + await assertSucceeds(setDoc(doc(admin.firestore(), 'products/new-item'), { name: 'New Item' })) + }) + + it('allows anyone to create a booking', async () => { + const unauthed = testEnv.unauthenticatedContext() + await assertSucceeds( + addDoc(collection(unauthed.firestore(), 'bookings'), { + serviceId: 'knotless', + date: '2026-09-01', + time: '10:00 AM', + contact: { name: 'Jane', phone: '555-0100', email: 'jane@example.com', notes: '' }, + status: 'confirmed', + }), + ) + }) + + it('blocks a non-admin from reading bookings (PII protection)', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'bookings/b1'), { + serviceId: 'knotless', + contact: { name: 'Jane', phone: '555-0100', email: 'jane@example.com', notes: '' }, + }) + }) + const unauthed = testEnv.unauthenticatedContext() + await assertFails(getDoc(doc(unauthed.firestore(), 'bookings/b1'))) + }) + + it('allows the admin to read bookings', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'bookings/b1'), { serviceId: 'knotless' }) + }) + const admin = testEnv.authenticatedContext(ADMIN_UID) + await assertSucceeds(getDoc(doc(admin.firestore(), 'bookings/b1'))) + }) + + it('blocks a non-admin from reading inquiries (PII protection)', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'inquiries/i1'), { + productId: 'edge-control', + contact: { name: 'Jane', email: 'jane@example.com', notes: '' }, + }) + }) + const unauthed = testEnv.unauthenticatedContext() + await assertFails(getDoc(doc(unauthed.firestore(), 'inquiries/i1'))) + }) + + it('allows anyone to read bookedSlots (no PII there)', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'bookedSlots/2026-09-01'), { times: ['10:00 AM'] }) + }) + const unauthed = testEnv.unauthenticatedContext() + await assertSucceeds(getDoc(doc(unauthed.firestore(), 'bookedSlots/2026-09-01'))) + }) + + it('blocks a non-admin from writing bookedSlots directly', async () => { + const unauthed = testEnv.unauthenticatedContext() + await assertFails(setDoc(doc(unauthed.firestore(), 'bookedSlots/2026-09-01'), { times: ['1:00 PM'] })) + }) +}) +``` + +- [ ] **Step 4: Add the `test:rules` script to `package.json`** + +Add under `"scripts"`: + +```json +"test:rules": "firebase emulators:exec --only firestore,storage --project demo-braids-by-mira \"vitest run --config vitest.rules.config.ts\"" +``` + +- [ ] **Step 5: Run the rules tests** + +Run: `npm run test:rules` +Expected: the emulator starts, all 9 tests pass, emulator shuts down. + +If this is the first time running the Firestore emulator, the Firebase CLI may prompt to download it — allow that (it's a one-time local download, not a network call from application code). + +- [ ] **Step 6: Verify the existing app suite is unaffected** + +Run: `npx vitest run` +Expected: still 50 tests passing (the rules tests live in `firebase-tests/` with their own config, excluded from the default `vitest.config.ts` run — confirm `firebase-tests/` isn't picked up by the default config; if it is, add `exclude: ['firebase-tests/**']` to `vite.config.ts`'s `test` block). + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json vitest.rules.config.ts firebase-tests +git commit -m "test: add Firestore/Storage security rules tests" +``` + +--- + +### Task 5: Cloud Functions scaffold and notification email builders + +**Files:** +- Create: `functions/package.json` +- Create: `functions/tsconfig.json` +- Create: `functions/src/email.ts` +- Test: `functions/src/email.test.ts` + +**Interfaces:** +- Produces: `buildBookingNotificationEmail(input: BookingEmailInput): { subject: string; html: string }` and `buildInquiryNotificationEmail(input: InquiryEmailInput): { subject: string; html: string }` — pure functions, no Firebase/network dependency, used by Task 6's actual Cloud Functions. + +- [ ] **Step 1: Create the Functions project scaffold** + +`functions/package.json`: + +```json +{ + "name": "functions", + "private": true, + "type": "module", + "engines": { "node": "20" }, + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "firebase-admin": "^12.0.0", + "firebase-functions": "^5.0.0", + "resend": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^4.1.10" + } +} +``` + +`functions/tsconfig.json`: + +```json +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "es2022", + "outDir": "lib", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 2: Install Functions dependencies** + +```bash +cd functions && npm install && cd .. +``` + +- [ ] **Step 3: Write the failing test** + +`functions/src/email.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { buildBookingNotificationEmail, buildInquiryNotificationEmail } from './email' + +describe('buildBookingNotificationEmail', () => { + it('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' }, + }) + expect(subject).toBe('New booking: Knotless Braids on 2026-09-01') + expect(html).toContain('Jane Doe') + expect(html).toContain('Knotless Braids') + expect(html).toContain('2026-09-01') + expect(html).toContain('10:00 AM') + expect(html).toContain('555-0100') + expect(html).toContain('jane@example.com') + expect(html).toContain('First time client') + }) + + it('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: '' }, + }) + expect(html).not.toContain('Notes:') + }) +}) + +describe('buildInquiryNotificationEmail', () => { + it('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?' }, + }) + expect(subject).toBe('New inquiry: Silk Edge Control') + expect(html).toContain('Nadia') + expect(html).toContain('nadia@example.com') + expect(html).toContain('Silk Edge Control') + expect(html).toContain('Is this in stock?') + }) + + it('omits the notes paragraph when notes are empty', () => { + const { html } = buildInquiryNotificationEmail({ + productName: 'Braid Oil', + contact: { name: 'Kim', email: 'kim@example.com', notes: '' }, + }) + expect(html).not.toContain('Notes:') + }) +}) +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `cd functions && npx vitest run src/email.test.ts` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 5: Implement `functions/src/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 ? `

Notes: ${input.contact.notes}

` : '' + return { + subject: `New booking: ${input.serviceName} on ${input.date}`, + html: `

${input.contact.name} booked ${input.serviceName} on ${input.date} at ${input.time}.

` + + `

Phone: ${input.contact.phone}
Email: ${input.contact.email}

${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 ? `

Notes: ${input.contact.notes}

` : '' + return { + subject: `New inquiry: ${input.productName}`, + html: `

${input.contact.name} (${input.contact.email}) asked about ${input.productName}.

${notesLine}`, + } +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cd functions && npx vitest run src/email.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 7: Verify the Functions project type-checks** + +Run: `cd functions && npm run build` +Expected: succeeds, produces `functions/lib/`. + +- [ ] **Step 8: Add `functions/lib` and `functions/node_modules` to `.gitignore`** + +Append to the root `.gitignore`: + +``` +functions/lib +functions/node_modules +``` + +- [ ] **Step 9: Commit** + +```bash +git add functions/package.json functions/package-lock.json functions/tsconfig.json functions/src/email.ts functions/src/email.test.ts .gitignore +git commit -m "feat: add Cloud Functions scaffold with tested email builders" +``` + +--- + +### Task 6: `onBookingCreated` and `onInquiryCreated` Cloud Functions + +**Files:** +- Create: `functions/src/index.ts` + +**Interfaces:** +- Consumes: `buildBookingNotificationEmail`, `buildInquiryNotificationEmail` from Task 5's `functions/src/email.ts`; the **Resend `from` address** and **Mira's real email** from Task 1 — the controller substitutes both as literal values when dispatching this task. +- Produces: `onBookingCreated` and `onInquiryCreated`, the two exports Firebase Functions deploys — these are thin glue code (not unit tested directly; their logic is already covered by Task 5's tests plus this task's build/typecheck verification and Task 8's live smoke test). + +- [ ] **Step 1: Implement `functions/src/index.ts`** + +Replace `bookings@braidsbymira.com` with the real Resend `from` address and `hello@braidsbymira.com` with Mira's real notification email, both from Task 1: + +```ts +import { onDocumentCreated } from 'firebase-functions/v2/firestore' +import { defineSecret } from 'firebase-functions/params' +import { initializeApp } from 'firebase-admin/app' +import { getFirestore, FieldValue } from 'firebase-admin/firestore' +import { Resend } from 'resend' +import { buildBookingNotificationEmail, buildInquiryNotificationEmail } from './email.js' + +initializeApp() + +const resendApiKey = defineSecret('RESEND_API_KEY') +const FROM_ADDRESS = 'bookings@braidsbymira.com' +const MIRA_EMAIL = 'hello@braidsbymira.com' + +export const onBookingCreated = onDocumentCreated( + { document: 'bookings/{bookingId}', secrets: [resendApiKey] }, + async (event) => { + const booking = event.data?.data() + if (!booking) return + + const db = getFirestore() + await db + .doc(`bookedSlots/${booking.date}`) + .set({ times: FieldValue.arrayUnion(booking.time) }, { merge: true }) + + const resend = new Resend(resendApiKey.value()) + const { subject, html } = buildBookingNotificationEmail({ + serviceName: booking.serviceId, + date: booking.date, + time: booking.time, + contact: booking.contact, + }) + await resend.emails.send({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }) + }, +) + +export const onInquiryCreated = onDocumentCreated( + { document: 'inquiries/{inquiryId}', secrets: [resendApiKey] }, + async (event) => { + const inquiry = event.data?.data() + if (!inquiry) return + + const resend = new Resend(resendApiKey.value()) + const { subject, html } = buildInquiryNotificationEmail({ + productName: inquiry.productId, + contact: inquiry.contact, + }) + await resend.emails.send({ from: FROM_ADDRESS, to: MIRA_EMAIL, subject, html }) + }, +) +``` + +- [ ] **Step 2: Verify the Functions project still type-checks and builds** + +Run: `cd functions && npm run build` +Expected: succeeds with zero TypeScript errors. + +- [ ] **Step 3: Set the Resend API key as a Cloud Functions secret** + +This requires the authenticated Firebase CLI session from Task 1 — run it yourself (or via `!` prefix), it prompts for the secret value interactively: + +```bash +npx firebase-tools functions:secrets:set RESEND_API_KEY +``` + +- [ ] **Step 4: Run the existing Functions unit tests once more to confirm nothing broke** + +Run: `cd functions && npx vitest run` +Expected: the 4 tests from Task 5 still pass (this task didn't modify `email.ts`). + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/index.ts +git commit -m "feat: add onBookingCreated and onInquiryCreated Cloud Functions" +``` + +--- + +### Task 7: Seed script + +**Files:** +- Create: `scripts/seed.ts` +- Modify: `package.json` (new devDependency, new script) + +**Interfaces:** +- Consumes: `products` from `src/data/products.ts`, `services` from `src/data/services.ts` (both unchanged from Phase 1) — read-only, this task never modifies those files. +- Produces: a `seed` npm script that populates `products/{id}`, `services/{id}`, and `scheduleTemplate/default` in Firestore, stripping each `image` field's leading `/` so it matches the Storage path convention from the spec (e.g. `/products/edge-control.jpg` → `products/edge-control.jpg`). + +- [ ] **Step 1: Install `tsx` for running the script** + +```bash +npm install -D tsx firebase-admin +``` + +- [ ] **Step 2: Write `scripts/seed.ts`** + +```ts +import { initializeApp } from 'firebase-admin/app' +import { getFirestore } from 'firebase-admin/firestore' +import { products } from '../src/data/products.js' +import { services } from '../src/data/services.js' + +initializeApp() +const db = getFirestore() + +function toStoragePath(image: string): string { + return image.replace(/^\//, '') +} + +async function seed() { + const batch = db.batch() + + for (const product of products) { + const { id, image, ...rest } = product + batch.set(db.doc(`products/${id}`), { ...rest, image: toStoragePath(image) }) + } + + for (const service of services) { + const { id, image, ...rest } = service + batch.set(db.doc(`services/${id}`), { ...rest, image: toStoragePath(image) }) + } + + batch.set(db.doc('scheduleTemplate/default'), { + workingDays: [2, 3, 4, 5, 6], + startTime: '09:00', + endTime: '18:00', + slotMinutes: 120, + }) + + await batch.commit() + console.log(`Seeded ${products.length} products, ${services.length} services, and the default schedule.`) +} + +seed().catch((err) => { + console.error(err) + process.exit(1) +}) +``` + +- [ ] **Step 3: Add the `seed` script to `package.json`** + +Add under `"scripts"`: + +```json +"seed": "tsx scripts/seed.ts" +``` + +- [ ] **Step 4: Dry-run the seed script against the Firestore emulator (not production yet)** + +```bash +npx firebase-tools emulators:exec --only firestore --project demo-braids-by-mira "FIRESTORE_EMULATOR_HOST=localhost:8080 npm run seed" +``` + +Expected output: `Seeded 10 products, 4 services, and the default schedule.` with no errors. This proves the script's logic works without touching the real production database yet — running it against production happens in Task 8, once you're ready. + +- [ ] **Step 5: Verify the rest of the suite is unaffected** + +Run: `npx vitest run` +Expected: still passing, unchanged count (this task adds no Vitest-run test files — the seed script is verified by Step 4's emulator dry run, not a unit test, since its job is a Firestore side effect). + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json scripts/seed.ts +git commit -m "feat: add Firestore seed script for products, services, and default schedule" +``` + +--- + +### Task 8: Deploy and live verification + +**Files:** +- No new files — this task deploys and smoke-tests the real Firebase project. + +**Interfaces:** +- Consumes: everything from Tasks 1–7. Requires the authenticated Firebase CLI session and the Resend secret from Task 1/Task 6 to already be in place. + +- [ ] **Step 1: Deploy Firestore rules, Storage rules, and Cloud Functions** + +```bash +npx firebase-tools deploy --only firestore:rules,storage:rules,functions +``` + +Expected: deploy succeeds, prints the deployed function URLs/triggers (background triggers don't have public URLs, but the CLI confirms both functions deployed). + +- [ ] **Step 2: Seed the real production database** + +```bash +npm run seed +``` + +Expected: `Seeded 10 products, 4 services, and the default schedule.` — confirm in the Firebase console (Firestore Database) that `products`, `services`, and `scheduleTemplate/default` now exist with the expected data. + +- [ ] **Step 3: Verify rules deny/allow correctly against the real project** + +Re-run the emulator-based rules tests one more time to confirm the rules deployed match what was tested: + +```bash +npm run test:rules +``` + +Expected: all 9 tests still pass. + +- [ ] **Step 4: End-to-end smoke test — create a real booking and confirm the function fires** + +In the Firebase console, go to Firestore Database → `bookings` collection → Add document, with fields matching the shape from the spec: + +``` +serviceId: "knotless" +date: "2026-09-15" +time: "10:00 AM" +contact: (map) { name: "Test Client", phone: "555-0100", email: "test@example.com", notes: "Smoke test" } +status: "confirmed" +``` + +Expected, within a few seconds: +- A new document appears at `bookedSlots/2026-09-15` with `times: ["10:00 AM"]`. +- An email arrives at Mira's real inbox (the `MIRA_EMAIL` address from Task 6) with subject "New booking: knotless on 2026-09-15". + +- [ ] **Step 5: End-to-end smoke test — create a real inquiry and confirm the function fires** + +Same pattern, add a document to `inquiries`: + +``` +productId: "edge-control" +contact: (map) { name: "Test Client", email: "test@example.com", notes: "Smoke test inquiry" } +status: "new" +``` + +Expected: an email arrives at Mira's inbox with subject "New inquiry: edge-control". + +- [ ] **Step 6: Clean up the smoke-test documents** + +Delete the test `bookings`, `inquiries`, and `bookedSlots/2026-09-15` documents created in Steps 4–5 via the Firebase console, so they don't linger as fake data. + +- [ ] **Step 7: Final confirmation that the existing frontend is completely unaffected** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: all pass, unchanged from before this entire plan — proving Sub-project A shipped a working backend without touching the live site. + +--- + +## Self-Review Notes + +- **Spec coverage:** tech stack (Task 2, 5), data model + PII separation (Task 3, verified by Task 4), security rules (Task 3, Task 4), Cloud Functions (Task 5, 6), client integration (Task 2), seeding (Task 7), verification (Task 8). 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. +- **No placeholders:** every step has literal file contents and exact commands, except the intentional `ADMIN_UID` / project ID / email-address substitutions that Task 1 explicitly produces as real values for the controller to carry into later dispatches — these are flagged, not vague TODOs.