Canonical source: docs/claude/firestore-schema.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
Firestore Schema, Rules, Types & Cross-Platform Data
Root Collections
| Collection | Purpose | Access |
|---|---|---|
users | User profiles and preferences | Owner + delegated clinicians |
users/{uid}/healthSummaries | Health data summaries | Owner + clinic staff (if active member) |
users/{uid}/events | User events (meals, workouts, etc.) | Owner + clinic staff |
users/{uid}/delegations | Per-clinician access delegations | System |
chats | Chat messages between users/staff | Owner + clinic staff |
admin | System administrators | Read-only public, write via console |
permissions | RBAC permission documents | System managed |
clinicsv2 | Clinic organizations (USE THIS, not clinics) | Staff + members |
clinics | LEGACY - Do not use | Deprecated |
booking_pages | Public booking pages | Public read, staff write |
Clinic Subcollections (clinicsv2/{clinicId}/...)
| Subcollection | Purpose |
|---|---|
clinic_users/{uid} | Client/patient records |
clinic_users/{uid}/healthSummaries | Mirrored health data |
clinic_users/{uid}/labs | Lab results |
clinic_users/{uid}/documentLinks | Document references |
clinic_users/{uid}/goals | User goals |
clinic_users/{uid}/conditions | Medical conditions |
clinic_users/{uid}/tasks | User tasks |
clinic_users/{uid}/copilot_sessions | AI copilot chat sessions |
clinicians/{uid} | Staff/clinician records |
clinicians/{uid}/availabilityV2 | Recurring availability rules |
public_clinicians/{uid} | Public projection of clinician info |
scheduled/{eventId} | Appointments and scheduled events |
scheduled/{eventId}/notes | Appointment notes |
transactions/{id} | Audit trail for clinic operations |
holidays/{id} | Clinic closures |
benchmarks/{code} | Clinic-level benchmark policies |
settings/copilot | AI copilot configuration |
settings/onboardingState | Onboarding tracking |
preferences/{id} | Clinic preferences |
noteTemplates/{id} | Note templates |
tasks/{id} | Clinic-level tasks |
config/zoom | Zoom integration config |
config/google | Google Meet config |
config/email | Branded email config |
config/fullscript | Fullscript integration |
config/junction | Junction Health integration |
settings/onboarding | Onboarding steps configuration |
Firestore Write Patterns for Basis Flow Web
CRITICAL: When writing to Firestore from the web app:
-
Use subcollections for settings - Write to
clinicsv2/{clinicId}/settings/{settingName}- Use
setDoc(ref, data, { merge: true })for create/update
- Use
-
Use cloud functions for main clinic doc - If you MUST update
clinicsv2/{clinicId}directly:- Use
clinic_servicewith appropriaterequest_type - Cloud functions run with admin privileges
- Use
-
Use dedicated subcollections for data:
noteTemplates/{id},formDefinitions/{id},tasks/{id},benchmarks/{code}
NEVER directly updateDoc on clinicsv2/{clinicId} from web client — it will fail with "Missing or insufficient permissions" unless the user has clinic_admin role.
Firestore Security Rules
ALWAYS add rules AND DEPLOY before writing new paths. Every new subcollection path needs an explicit rule in basis-functions/firestore.rules.
Adding the rule to the file is NOT enough — you MUST deploy:
cd basis-functions && firebase deploy --only firestore:rules
Each settings/{name} document needs its OWN rule
There is NO wildcard match /settings/{doc} catch-all. Every new settings document path must have an explicit rule.
Pre-flight checklist before writing any new Firestore path:
grepthe path infirestore.rules— if not found, ADD it firstgrepthe path instorage.rules— if files are involved- DEPLOY the rules — the file change alone does NOTHING until deployed
- Only then write the frontend/backend code
- Test the write in the browser before marking done
Recurring Bugs
- Adding a rule without deploying it — Firestore doesn't know about it until
firebase deploy --only firestore:rules - After deploying new rules, existing
onSnapshotlisteners remain broken — the SDK caches the permission denial. Always hard-refresh (Cmd+Shift+R) after deploying new rules. - Changing Pydantic models without redeploying the function — Frontend sends new fields, deployed backend has old model. Redeploy the function before testing.
Firebase Storage Rules
When adding file upload functionality, verify the storage path has a corresponding rule in basis-functions/storage.rules.
hasPermission in storage.rules
Checks permissions/{clinicId}/users/{uid} for the permission. Checks BOTH arrays:
implicit_permissions(current — expanded permissions)permissions(legacy fallback)- Also grants access if
allis present in either array
Existing Allowed Paths (clinicsv2/{clinicId}/...)
| Path | Read | Write Permission |
|---|---|---|
/logo/** | authenticated | edit_clinic |
/users/{userId}/** | owner, admin, view_documents | owner, admin, edit_documents |
/staff/{staffId}/profile/** | authenticated | owner, admin, edit_admins |
/products/** | authenticated | admin, edit_clinic |
/templates/.../references/** | admin, view_documents | admin, edit_documents |
/knowledge/** | admin, view_documents | admin, edit_documents |
Other Paths
| Path | Read | Write Permission |
|---|---|---|
website/{clinicId}/** | authenticated | admin, edit_clinic |
users/{userId}/** | owner, admin | owner, admin |
Document Storage Path Conventions (CRITICAL)
The Flutter app and Web app use different Storage paths for the same files. This is the #1 cause of "Unable to load preview" errors.
| Platform | Upload Path | Firestore storagePath field |
|---|---|---|
| Flutter app (share/upload) | users/{uid}/files/{uuid}.ext | users/{uid}/files/{uuid}.ext |
| Flutter app (lab upload) | users/{uid}/labs/{uuid}.ext | users/{uid}/labs/{uuid}.ext |
| Web app (ImportLabModal) | clinicsv2/{clinic}/users/{uid}/files/labs/{timestamp}_{name} | Full clinicsv2 path |
| Web app (DocumentsTab upload) | clinicsv2/{clinic}/users/{uid}/files/documents/{name} | Full clinicsv2 path |
A Storage trigger copies files from users/{uid}/files/ to clinicsv2/{clinic}/users/{uid}/files/, but NOT from users/{uid}/labs/.
Firestore collections that reference files:
documentLinks— written by Flutter, storesusers/{uid}/...paths (need rewrite for web)documentSummaries— written by both platforms, stores either format
When loading previews, always try multiple path variants:
- The stored
storagePathas-is - If it has
/labs/, also try/files/(and vice versa) - If it's a
clinicsv2/path, also try the rawusers/path
Adding New Upload Paths
// In storage.rules, inside match /clinicsv2/{clinicId} { ... }
match /your_new_path/{allPaths=**} {
allow read: if request.auth != null;
allow write: if request.auth != null && (
isAdmin() || hasPermission(clinicId, 'all') || hasPermission(clinicId, 'required_permission')
);
}
Pydantic <-> Firestore Type Compatibility
Data in Firestore can be written from multiple sources with different serialization formats. When the Python backend reads with Pydantic models, type mismatches cause crashes.
Solution: Type Validators in model_clinic.py
from typing import Annotated
from pydantic import BeforeValidator
DateOfBirthField = Annotated[str | None, BeforeValidator(_parse_date_of_birth)]
ClinicianStatusField = Annotated[ClinicianStatus, BeforeValidator(_parse_clinician_status)]
FlexibleDatetimeField = Annotated[datetime | None, BeforeValidator(_parse_flexible_datetime)]
GenderField = Annotated[Gender, BeforeValidator(_parse_gender)]
ClinicUserStatusField = Annotated[ClinicUserStatus, BeforeValidator(_parse_clinic_user_status)]
Current Validators
| Field Type | Accepts | Returns | Fallback |
|---|---|---|---|
DateOfBirthField | Timestamp, datetime, string, None | ISO date string "YYYY-MM-DD" | None |
ClinicianStatusField | String, int, enum | ClinicianStatus enum | ACTIVE |
GenderField | String "f"/"Female", int, enum | Gender enum | UNKNOWN |
ClinicUserStatusField | String, int, enum | ClinicUserStatus enum | ACTIVE |
FlexibleDatetimeField | Timestamp, ISO string, None | datetime object | None |
Response Serialization
When returning data from clinic_service:
if isinstance(result, BaseModel):
result = model_to_firestore(result) # Uses exclude_defaults=True
Document IDs (like entryId) are metadata NOT in doc.to_dict(). For functions where document ID is important, return list[dict] instead of list[Model].
Common Gotchas
- Don't assume field types are consistent — Same field can be Timestamp in one doc, string in another
- Always have fallback values — Validators should gracefully degrade, not crash
- Test with real Firestore data — Unit tests with clean data won't catch these issues
- Check both read AND write paths
Field Naming Conventions
- Cloud Function requests: Use snake_case (
image_url,stripe_price_one_time) - Firestore storage: Use camelCase (
imageUrl,stripePriceOneTime) - Frontend interfaces: Use camelCase to match Firestore
- Pydantic models handle conversion via Field aliases
# CORRECT: Use camelCase directly
class MembershipType(BaseModel):
primaryLocationId: Optional[str] = Field(None)
accessibleLocationIds: list[str] = Field(default_factory=list)
# WRONG: snake_case with aliases causes issues
class MembershipType(BaseModel):
primary_location_id: Optional[str] = Field(None, alias='primaryLocationId') # DON'T
Firestore Auth Rules for Public Data
clinicsv2/{clinicId}requires authentication to read- For public/pre-auth data (e.g., sign-in pages with clinic branding), use Cloud Functions which have admin access
- Example:
resolve_clinic_by_subdomainfetches clinic info for branded login pages without requiring user auth
Cross-Platform Data Changes
Changes affect hundreds of clinics with different configurations and tens of thousands of patients.
The Problem
A common failure mode:
- Added UI components in frontend
- Added fields to TypeScript interface
- Did NOT verify backend Pydantic model accepts the fields
- Did NOT verify backend storage function preserves the fields
- Did NOT verify other platforms can read the new data
The Reality
- Pydantic v2 silently drops unknown fields
- Storage functions explicitly list fields — New fields aren't auto-included
- TypeScript interfaces don't validate runtime — Backend must also accept
- Frontend-only changes are incomplete
Required Verification Checklist
- Backend Model — Field added to Pydantic model + included in storage/serialization
- Firestore Storage — Field is being written correctly + security rules allow it
- All Platforms Read Correctly — Basis Flow Web, Basis Web, Basis Hybrid
- Backwards Compatibility — Old data without the field doesn't crash
Trace the Full Data Lifecycle
Frontend (sends) -> Backend Model (accepts) -> Storage Function (persists) -> Firestore -> All Consumers (read)
| Step | Check |
|---|---|
| Frontend sends | Payload includes field |
| Backend accepts | Pydantic model has field |
| Storage persists | Storage function includes field |
| Firestore writes | Document has field |
| Other platforms read | All apps can parse |
Common Cross-Platform Data Locations
| Data Type | Backend Model | Storage Function | Firestore Path |
|---|---|---|---|
| Protocols/Habits | HabitEventInput | _habit_min() | clinicsv2/{clinic}/clinic_users/{uid}/habits |
| Appointments | Event | book_appointment() | clinicsv2/{clinic}/scheduled/{id} |
| Labs | LabResult | via document_processor | clinicsv2/{clinic}/clinic_users/{uid}/labs |
| User profile | User | various | users/{uid} |
| Clinic settings | ClinicDetails | update_clinic() | clinicsv2/{clinic} |
A data field doesn't exist until it's stored in Firestore and readable by all platforms.