chore: remove Firebase backend code, replaced by Supabase (see 2026-08-08 spec)
Deleted all Firebase-specific files and directories: - Configuration: .firebaserc, firebase.json, firestore.* files, storage.rules - Cloud Functions: functions/ directory (package.json, source, config) - Tests: firebase-tests/, vitest.rules.config.ts - Scripts: scripts/seed.ts - Source: src/lib/firebase.ts and .test.ts - Environment: .env.example Removed dependencies: - firebase (main package) - firebase-admin, firebase-tools (dev) - @firebase/rules-unit-testing (dev) Removed scripts: - test:rules (Firebase emulator tests) - seed (Firestore seed script) Updated package.json via npm install (729 packages removed) Updated .gitignore (removed functions/ entries) Updated vite.config.ts (removed firebase-tests and functions exclusions) All tests, builds, and lints pass cleanly. No Firebase references remain in the codebase (only in historical spec documents in docs/). Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"projects": {
|
|
||||||
"default": "demo-braids-by-mira"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Cloud Functions
|
|
||||||
functions/lib
|
|
||||||
functions/node_modules
|
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Phase 2A — Remaining work before this backend goes live
|
|
||||||
|
|
||||||
This backend (Firestore, Storage, Cloud Functions) was built entirely with placeholder
|
|
||||||
values because Task 1 of `docs/superpowers/plans/2026-08-04-phase2a-backend-foundation.md`
|
|
||||||
(manual Firebase/Resend account setup) hadn't been done yet. Before deploying for real:
|
|
||||||
|
|
||||||
## 1. Complete Task 1 of the plan
|
|
||||||
Create the Firebase project, upgrade to Blaze, enable Firestore/Auth/Storage,
|
|
||||||
create Mira's admin user, register a web app, create a Resend account + API key,
|
|
||||||
authenticate the Firebase CLI. Full instructions are in the plan's Task 1.
|
|
||||||
|
|
||||||
## 2. Replace these 4 placeholder values with real ones from Task 1
|
|
||||||
|
|
||||||
| Placeholder | Real value goes in | Also appears in |
|
|
||||||
|---|---|---|
|
|
||||||
| `demo-braids-by-mira` (fake project id) | `.firebaserc` | `package.json`'s `test:rules` script, `firebase-tests/firestore.rules.test.ts` |
|
|
||||||
| `dev-admin-placeholder-uid` (fake admin UID) | `firestore.rules`, `storage.rules` | `firebase-tests/firestore.rules.test.ts` |
|
|
||||||
| `bookings@braidsbymira.com` (placeholder from-address) | `functions/src/index.ts` (`FROM_ADDRESS`) | — must match a Resend-verified sending domain |
|
|
||||||
| `hello@braidsbymira.com` (placeholder Mira email) | `functions/src/index.ts` (`MIRA_EMAIL`) | — replace only if Mira's real notification inbox differs |
|
|
||||||
|
|
||||||
Also update `.env.local` (gitignored, not committed) with the real Firebase web app
|
|
||||||
config from Task 1, Step 5 — copy `.env.example` if you haven't already.
|
|
||||||
|
|
||||||
## 3. Run the one manual CLI step Task 6 skipped
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx firebase-tools functions:secrets:set RESEND_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Run Task 8 of the plan
|
|
||||||
|
|
||||||
Deploy rules + functions, seed production data, and run the two live smoke tests
|
|
||||||
(create a real booking/inquiry document, confirm `bookedSlots` updates and email
|
|
||||||
arrives). Full steps are in the plan's Task 8.
|
|
||||||
|
|
||||||
## 5. Delete this file
|
|
||||||
|
|
||||||
Once all of the above is done and verified, delete this file — it's a temporary
|
|
||||||
tracking note, not permanent documentation.
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { describe, it, 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 = 'dev-admin-placeholder-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 creating a booking that is missing a required field', async () => {
|
|
||||||
const unauthed = testEnv.unauthenticatedContext()
|
|
||||||
await assertFails(
|
|
||||||
addDoc(collection(unauthed.firestore(), 'bookings'), {
|
|
||||||
serviceId: 'knotless',
|
|
||||||
date: '2026-09-01',
|
|
||||||
time: '10:00 AM',
|
|
||||||
status: 'confirmed',
|
|
||||||
// missing contact
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks creating a booking with a malformed date', async () => {
|
|
||||||
const unauthed = testEnv.unauthenticatedContext()
|
|
||||||
await assertFails(
|
|
||||||
addDoc(collection(unauthed.firestore(), 'bookings'), {
|
|
||||||
serviceId: 'knotless',
|
|
||||||
date: 'not-a-date',
|
|
||||||
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('allows anyone to create an inquiry with a valid shape', async () => {
|
|
||||||
const unauthed = testEnv.unauthenticatedContext()
|
|
||||||
await assertSucceeds(
|
|
||||||
addDoc(collection(unauthed.firestore(), 'inquiries'), {
|
|
||||||
productId: 'edge-control',
|
|
||||||
contact: { name: 'Jane', email: 'jane@example.com', notes: '' },
|
|
||||||
status: 'new',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks creating an inquiry that is missing a required field', async () => {
|
|
||||||
const unauthed = testEnv.unauthenticatedContext()
|
|
||||||
await assertFails(
|
|
||||||
addDoc(collection(unauthed.firestore(), 'inquiries'), {
|
|
||||||
productId: 'edge-control',
|
|
||||||
status: 'new',
|
|
||||||
// missing contact
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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'] }))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"indexes": [],
|
|
||||||
"fieldOverrides": []
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
rules_version = '2';
|
|
||||||
service cloud.firestore {
|
|
||||||
match /databases/{database}/documents {
|
|
||||||
function isAdmin() {
|
|
||||||
return request.auth != null && request.auth.uid == "dev-admin-placeholder-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 request.resource.data.keys().hasAll(['serviceId', 'date', 'time', 'contact', 'status'])
|
|
||||||
&& request.resource.data.date is string
|
|
||||||
&& request.resource.data.date.matches('^[0-9]{4}-[0-9]{2}-[0-9]{2}$')
|
|
||||||
&& request.resource.data.time is string
|
|
||||||
&& request.resource.data.contact is map
|
|
||||||
&& request.resource.data.contact.keys().hasAll(['name', 'phone', 'email', 'notes'])
|
|
||||||
&& request.resource.data.status is string;
|
|
||||||
allow read, update, delete: if isAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
match /inquiries/{inquiryId} {
|
|
||||||
allow create: if request.resource.data.keys().hasAll(['productId', 'contact', 'status'])
|
|
||||||
&& request.resource.data.productId is string
|
|
||||||
&& request.resource.data.contact is map
|
|
||||||
&& request.resource.data.contact.keys().hasAll(['name', 'email', 'notes'])
|
|
||||||
&& request.resource.data.status is string;
|
|
||||||
allow read, update, delete: if isAdmin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4228
functions/package-lock.json
generated
4228
functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "functions",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"engines": { "node": "22" },
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { buildBookingNotificationEmail, buildInquiryNotificationEmail } from './email.js'
|
|
||||||
|
|
||||||
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:')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
if (typeof booking.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(booking.date)) return
|
|
||||||
if (typeof booking.time !== 'string' || booking.time.length === 0) 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 })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "nodenext",
|
|
||||||
"moduleResolution": "nodenext",
|
|
||||||
"target": "es2022",
|
|
||||||
"outDir": "lib",
|
|
||||||
"rootDir": "src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'node',
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
9970
package-lock.json
generated
9970
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,10 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run"
|
||||||
"test:rules": "firebase emulators:exec --only firestore,storage --project demo-braids-by-mira \"vitest run --config vitest.rules.config.ts\"",
|
|
||||||
"seed": "tsx scripts/seed.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-three/fiber": "^9.7.0",
|
"@react-three/fiber": "^9.7.0",
|
||||||
"firebase": "^12.17.1",
|
|
||||||
"framer-motion": "^12.43.0",
|
"framer-motion": "^12.43.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
@@ -23,7 +20,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@firebase/rules-unit-testing": "^5.0.1",
|
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.0",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -35,11 +31,8 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"firebase-admin": "^14.2.0",
|
|
||||||
"firebase-tools": "^15.25.1",
|
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"tsx": "^4.23.5",
|
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.2.0",
|
"vite": "^8.2.0",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
rules_version = '2';
|
|
||||||
service firebase.storage {
|
|
||||||
match /b/{bucket}/o {
|
|
||||||
function isAdmin() {
|
|
||||||
return request.auth != null && request.auth.uid == "dev-admin-placeholder-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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,8 +14,6 @@ export default defineConfig({
|
|||||||
'**/cypress/**',
|
'**/cypress/**',
|
||||||
'**/.{idea,git,cache,output,temp}/**',
|
'**/.{idea,git,cache,output,temp}/**',
|
||||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||||
'firebase-tests/**',
|
|
||||||
'functions/**',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'node',
|
|
||||||
include: ['firebase-tests/**/*.test.ts'],
|
|
||||||
testTimeout: 20000,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user