35 KiB
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/, orsrc/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. bookingsandinquiriesdocuments 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.bookedSlotsis written only by the Cloud Function (viafirebase-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.tsexportsapp(the initializedFirebaseApp),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.localand.env.*.localto.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.localfrom 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.rulesfrom Task 3 — read from disk at test time, withADMIN_UID(the real UID from Task 1) used directly since it's already literal in the committed rules files. -
Produces: a
test:rulesnpm 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:rulesscript topackage.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 }andbuildInquiryNotificationEmail(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/libandfunctions/node_modulesto.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,buildInquiryNotificationEmailfrom Task 5'sfunctions/src/email.ts; the Resendfromaddress and Mira's real email from Task 1 — the controller substitutes both as literal values when dispatching this task. -
Produces:
onBookingCreatedandonInquiryCreated, 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:
productsfromsrc/data/products.ts,servicesfromsrc/data/services.ts(both unchanged from Phase 1) — read-only, this task never modifies those files. -
Produces: a
seednpm script that populatesproducts/{id},services/{id}, andscheduleTemplate/defaultin Firestore, stripping eachimagefield'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
tsxfor 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
seedscript topackage.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-15withtimes: ["10:00 AM"]. -
An email arrives at Mira's real inbox (the
MIRA_EMAILaddress 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/InquiryEmailInputand the twobuild*NotificationEmailfunctions 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.