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

35 KiB
Raw Blame History

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):

npx firebase-tools login

Confirm success with:

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

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:

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
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.

cp .env.example .env.local
  • Step 10: Commit
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:

{
  "projects": {
    "default": "YOUR_PROJECT_ID"
  }
}
  • Step 2: Create firebase.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
{
  "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
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

npm install -D @firebase/rules-unit-testing firebase-tools
  • Step 2: Create vitest.rules.config.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):

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":

"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
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:

{
  "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:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "outDir": "lib",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
  • Step 2: Install Functions dependencies
cd functions && npm install && cd ..
  • Step 3: Write the failing test

functions/src/email.test.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
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 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
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:

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:

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
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

npm install -D tsx firebase-admin
  • Step 2: Write scripts/seed.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":

"seed": "tsx scripts/seed.ts"
  • Step 4: Dry-run the seed script against the Firestore emulator (not production yet)
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
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

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
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:

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.