β‘ Firebase Functions Overview
Serverless Event Processing
Firebase Cloud Functions provide the event-driven orchestration layer for user management, video processing, and report generation.
π― Repositoryβ
:::info Main Repository
| Repository | Commit | Region | Status |
|---|---|---|---|
| acesense-auth-function | 281f281 | europe-west1 | β Live |
| ::: |
This is now a standalone repository (migrated out of the monorepo). It contains the user lifecycle functions, the video pipeline orchestration, the public REST API (api/), the MCP server (mcp/), direct store and Stripe entitlement verification, admin callables, and feedback alert triggers.
Functions Summaryβ
| Function | Trigger | Version | Purpose |
|---|---|---|---|
createUserDocument | auth.user().onCreate | v1 | Creates user profile |
deleteUserDocument | auth.user().onDelete | v1 | Cascade-deletes ALL account data (F-9): profile, jobs, coach links/notes, API keys, and Storage `videos |
markEmailVerified | https.onCall | v1 | Flips verified flag + fires welcome email once |
exportUserData | https.onCall | v1 | Builds a zip (profile + sessions + PDF summaries), emails signed link |
requestAccountDeletion | https.onCall | v1 | Sends farewell email and deletes Auth user (Firestore cleanup via deleteUserDocument) |
processVideoOnUpload | storage.onObjectFinalized on videos/** | v2 | Generates a download URL for the uploaded video and hands it to the GPU router (Lambda) |
onResultUploaded | storage.onObjectFinalized on results/** | v2 | Looks up the job by matching resultPath, marks it done, stores resultUrl |
analyzeVideoOnProcessing | Firestore jobs/{jobId} onDocumentUpdated (uploadedβprocessing edge) | v2 | Vertex Gemini whole-video read (va2): timestamped shot events, per-player ratings, strengths/improvements with drills. Video passed as gs:// URI (bytes never transit the function); writes only jobs.videoAnalysis. Kill switch ACESENSE_VIDEO_ANALYSIS=0 |
mergeAnalysisReports | Firestore jobs/{jobId} onDocumentUpdated (both analyses terminal) | v2 | Deterministic GPU Γ Gemini merged report (vm2) on jobs.mergedReport β GPU counts authoritative only when the court was solved; court-free sessions surface the Gemini read as primary. Kill switch ACESENSE_MERGED_REPORT=0 |
requestUploadPath | https.onCall | v1 | Validates metadata, reserves quota, creates the jobs/{id} doc, returns { uploadPath, resultPath, jobId, sessionId, expiresAt, quota } (single-file upload) |
reportUploadFailure | https.onCall | v1 | Client-reported upload failure β fails the job and refunds reserved cost for API jobs |
mergeChunkResults | https.onCall | v1 | Legacy β merges per-chunk analyses. The client no longer chunks (single-file upload); retained for backward compatibility |
sendPasswordResetCustom | https.onCall | v1 | Sends a branded password-reset email |
deleteSession | https.onCall | v1 | Owner-scoped per-session erase: job doc + the session's Storage folders (video, result JSON, assets zip, annotated video, shot clips) |
reconcileStuckJobs | Cloud Scheduler (hourly) | v2 | Fails jobs stuck in pending/uploaded (>2 h) or processing (>6 h); releases the leaked concurrency slot, refunds API-job cost |
health | https.onRequest | v2 | Health check β returns { status, region, timestamp, version } |
onFeedbackCreated | Firestore feedback/{id} onDocumentCreated | v2 | Emails admin@acesense.io with the user's app feedback (text, email, UID) |
onDocsFeedbackCreated | Firestore docs_feedback/{id} onDocumentCreated | v2 | Emails admin@acesense.io for docs feedback (π always, π only when a note is attached) |
Public API / agent-commerce surface (api/, mcp/)β
| Function | Trigger | Version | Purpose |
|---|---|---|---|
apiServer | https.onRequest | v2 | Public REST API behind api.acesense.io/v1/** (fronted by acesense-api-hosting) |
mcpServer | https.onRequest | v2 | JSON-RPC MCP server at api.acesense.io/mcp |
createApiKey / listApiKeys / revokeApiKey | https.onCall | v2 | API key lifecycle for agent-commerce clients |
verifyGooglePlayPurchase / verifyApplePurchase | https.onCall | v2 | Verifies direct store purchases and mirrors entitlement state β users/{uid} |
googlePlayBillingNotification | Pub/Sub | v2 | Reconciles Google Play subscription lifecycle events |
appStoreServerNotification | https.onRequest | v2 | Reconciles App Store Server Notifications V2 |
syncTierToPlan | Firestore users/{uid} onDocumentWritten | v2 | Mirrors new tier field β legacy plan for backward-compatible consumers |
Admin-only callables (admin/) β gated by requireAdmin (Firestore allowlist)β
| Function | Trigger | Purpose |
|---|---|---|
adminRetryJob | https.onCall | Re-dispatch a failed job |
adminSetUserPlan | https.onCall | Override a user's plan/tier |
adminSetUserSuspended | https.onCall | Suspend / unsuspend a user |
adminAdjustApiKeyBalance | https.onCall | Credit/debit an API key balance |
adminRevokeSignedUrl | https.onCall | Revoke an outstanding signed URL |
bootstrapAdminClaims | https.onCall | Bootstrap admin custom claims |
adminListStorage | https.onCall | Server-side (Admin SDK) one-level bucket browse β storage.rules grant admins no client access |
adminGetStorageUrl | https.onCall | 15-min signed read URL; writes a storage_preview/storage_download audit row |
adminDeleteStorageObject | https.onCall | Atomic server-side delete + storage_delete audit row |
Coach Mode (coach/) β two-sided coachβplayer accountsβ
Pure consent/seat/note rules live in coach/links.ts + coach/notes.ts
(unit-tested, no Firebase); these callables wrap them with Firestore + claim I/O.
Collections (coaches, coachLinks, coachNotes) are server-write only;
Firestore rules grant party-read and forbid client writes.
| Function | Trigger | Version | Purpose |
|---|---|---|---|
becomeCoach | https.onCall | v2 | Grants the role: coach claim + upserts coaches/{uid} |
createCoachLink | https.onCall | v2 | Starts a pending link (two-sided consent); seat-checked (free β€ 3) |
respondToCoachLink | https.onCall | v2 | accept (non-initiator) / decline / revoke (either party) |
listCoachLinks | https.onCall | v2 | { asCoach[], asPlayer[] } for the caller |
addCoachNote | https.onCall | v2 | Coach writes a link-gated, append-only feedback note |
listCoachNotes | https.onCall | v2 | A (coach, player) pair's notes; either party reads |
π Auth Functionsβ
The acesense-auth-function repository handles user document lifecycle.
Functionsβ
| Function | Trigger | Description |
|---|---|---|
createUserDocument | auth.user().onCreate | Creates user profile from JSON template, enqueues welcome email when account is pre-verified |
deleteUserDocument | auth.user().onDelete | Cascade-deletes the account data (profile, jobs, coach data, API keys, media) β see GDPR section |
markEmailVerified | https.onCall | Re-checks emailVerified via Admin SDK, sets the doc flag, and fires the welcome email exactly once |
π¦ GDPR / Account Data Functionsβ
Introduced to satisfy the Export my data and Delete account surface in Settings.
Both callables require context.auth, operate only on the caller's data, and
deliver user-facing outcomes via the Trigger Email from Firestore extension
(writes a doc to the mail/ collection β extension delivers via SMTP).
exportUserDataβ
-
Trigger:
https.onCall -
Memory / timeout: 1 GB / 540 s (zip building + PDF rendering)
-
Flow:
- Collects the user's Firestore doc (
users/{uid}) - Fetches all
jobswithuserId == uidcreated within the last 30 days - For each job, downloads
resultPath(analysis JSON) and generates a server-side pdfkit summary PDF (text + stats, no charts) - Streams a zip to
exports/{uid}/export-{ts}.zipon Storage using archiver, attaching a Firebase download token in object metadata - Enqueues an email via the Trigger Email extension with the signed URL
- Collects the user's Firestore doc (
-
Zip contents:
README.mdprofile.jsonsessions.jsonsessions/{sessionId}/analysis.jsonsessions/{sessionId}/report.pdf -
Videos: intentionally excluded (hundreds of MB each). README directs users to email
info@acesense.iowith the session ID to request specific footage. Match video is retained for 30 days on a rolling window.
requestAccountDeletionβ
- Trigger:
https.onCall - Flow:
- Enqueues a farewell email (
accountDeletedEmail) via the same extension - Calls
admin.auth().deleteUser(uid)β this fires thedeleteUserDocumentonDeletetrigger, which now cascade-deletes the whole account (audit F-9):users/{uid}, alljobs, coach links/notes/profile, API keys (recursive), and Storagevideos|results/{uid}/**(user + API paths). Immutable audit trails and sentmaildocs are retained deliberately (pseudonymous; flagged for legal review)
- Enqueues a farewell email (
- Client side: after the callable resolves, the app also calls
FirebaseAuth.signOut()and pops to root as a courtesy so the UI doesn't sit on a stale token.
Dependency: Trigger Email from Firestoreβ
Install from the Extensions Hub (firebase/firestore-send-email). Required
config:
| Param | Value |
|---|---|
| Mail collection | mail |
| Authentication type | UsernamePassword |
| SMTP connection URI | smtps://resend:RESEND_API_KEY@smtp.resend.com:465 (or any SMTP provider) |
| Default FROM | AceSense <info@acesense.io> |
| Default REPLY-TO | info@acesense.io |
If the extension is not installed or misconfigured, mail docs accumulate
in mail/ with delivery.state == ERROR. Email templates live in
shared/email-templates.ts.
π¬ Feedback Alert Triggersβ
Two onDocumentCreated Firestore triggers fan an email to admin@acesense.io whenever a user submits feedback. Mail dispatch reuses the existing enqueueEmail() helper which writes to the mail/{id} collection β picked up by the Firebase Trigger Email from Firestore extension and sent via SMTP (Resend, verified acesense.io).
onFeedbackCreatedβ
- Trigger: v2
onDocumentCreatedonfeedback/{id} - Source:
feedback/index.ts(acesense-auth-function) - Source surface: Flutter app β Settings β "Send feedback" β
FirestoreService.submitFeedback() - Effect: builds an HTML + plaintext email containing the user's text, email, UID, Firestore deeplink. Always alerts; skips silently when
textis empty.
onDocsFeedbackCreatedβ
- Trigger: v2
onDocumentCreatedondocs_feedback/{id} - Source:
feedback/index.ts(acesense-auth-function) - Source surface: Docusaurus
<DocFeedback />widget β docsdocsServerPOST /__/feedbackβ Admin SDK write - Effect: alerts on π always and on π only when a note is attached (avoids inbox spam from low-signal positive clicks).
Firestore rulesβ
feedback/ is admin-read and authenticated-create. docs_feedback/ is admin-read and server-created only: the Docusaurus widget posts to the admin-gated docs server, and that server writes with Admin SDK. Update and delete are forbidden so feedback can't be tampered with after submission.
match /feedback/{id} {
allow read: if isAdmin();
allow create: if request.auth != null
&& request.resource.data.text is string
&& request.resource.data.text.size() > 0
&& request.resource.data.text.size() <= 10000;
allow update, delete: if false;
}
match /docs_feedback/{id} {
allow read: if isAdmin();
allow create, update, delete: if false;
}
π¬ Video Functionsβ
The acesense-auth-function repository also handles video processing.
Video Functions (video/)β
processVideoOnUploadβ
- Trigger: Storage
onObjectFinalized(v2) - Purpose: Automatically triggers when a video lands under
videos/{userId}/{sessionId}/β¦in Storage - Flow (single-file upload β no client chunking):
- Magic-byte gate β verifies the container matches the extension
- Mints a short-lived signed URL (β€ 30 min TTL,
RUNPOD_SIGNED_URL_TTL_MS) ffprobegate β validates duration/codec/resolution bounds- Cloud Vision first-frame moderation
- Budget gate (
assertJobBudget) before dispatch - Dispatches to the GPU router with the signed
video_url+ derivedresult_path. RunPod was removed 2026-08-16; the Lambda pull-worker fleet is the only provider - On any terminal failure, calls
failJob()β releases the user's concurrency slot and refunds reserved cost for API jobs (see Billing & refunds below)
requestUploadPathβ
- Trigger: HTTPS Callable
- Purpose: Validates video metadata, reserves upload quota, creates the
jobs/{id}doc, and returns the single-file upload target - Returns:
{ uploadPath, resultPath, jobId, sessionId, expiresAt, quota }βquotalets the client render an "X/Y today" badge. Job doc is written withisChunked: false, totalChunks: 1, chunkIndex: 0.
reportUploadFailureβ
- Trigger: HTTPS Callable
- Purpose: Client signals that the upload itself failed (network drop, cancelled). Fails the job and triggers the automatic refund path for API jobs.
mergeChunkResults (legacy)β
- Trigger: HTTPS Callable
- Status: Legacy. The client switched to single-file upload, so this no longer runs in the normal flow. Retained for backward compatibility only.
π³ Billing & automatic refundsβ
API-key jobs reserve their estimated cost up front. creditFailedJob(jobId) (api/billing-meter.ts) refunds that reserved cost whenever a job ends in failed β invoked from failJob() (video/index.ts) on every infra-failure path (magic-byte, ffprobe, moderation, provider-unconfigured, dispatch error, budget gate). It is idempotent via a costRefundedAt marker (refund action refund_failed). assertJobBudget() provides a defence-in-depth re-check immediately before GPU dispatch.
Per-user quota (QUOTA_LIMITS in shared/config.ts) tracks daily counts + concurrency slots in users/{uid}.usage; the concurrency slot is released on any terminal state. Tiers (free β paywall, premium/ultimate monthly caps) gate uploads in video/index.ts.
onResultUploadedβ
- Trigger: Storage
onObjectFinalized(v2) - Purpose: Automatically updates job status when analysis results are uploaded
- Flow:
- Triggers on
results/*_combined.jsonfile uploads - Queries Firestore for job matching
resultPath - Updates job status to "done" with completion timestamp
- Generates and stores the
resultUrlfor frontend access
- Triggers on
Architectureβ
π Auth Architectureβ
User Document Schemaβ
The user schema is data-driven via templates/user-template.json.
π Schema Overviewβ
| Section | Fields | Description |
|---|---|---|
| Core | uid, email, displayName, photoURL | From Firebase Auth |
| Plan (top-level) | plan, trialEndsAt, jobCount | Fast-read trial/subscription state |
| Personal Info | name, DOB, gender, phone, nationality | User profile details |
| Location | country, city, timezone, homeClub | Geographic & club info |
| Coaching | currentCoach, history, preferences | Coach relationships |
| Tennis Profile | skillLevel, dominantHand, playingStyle, goals | Playing background (filled by onboarding) |
| Onboarding | completed, step, startedAt, completedAt | 6-step onboarding state |
| Performance | overallRating, skillRatings, statistics | AI-calculated metrics |
| Analysis Usage | totals, monthly, types breakdown | Usage tracking |
| Subscription | plan, credits, trial dates, limits | Billing & limits |
| Privacy | consent, marketing, analytics | GDPR compliance |
π€ Personal Infoβ
| Field | Type | Default | Description |
|---|---|---|---|
firstName | string | "" | Parsed from displayName |
lastName | string | "" | Parsed from displayName |
dateOfBirth | timestamp | null | User's birth date |
gender | string | null | male/female/other/prefer_not_to_say |
phoneNumber | string | null | Contact number |
nationality | string | null | Country code (ISO) |
π Locationβ
| Field | Type | Default | Description |
|---|---|---|---|
country | string | null | Country of residence |
city | string | null | City of residence |
timezone | string | null | IANA timezone |
homeClub.name | string | null | Tennis club name |
homeClub.membershipId | string | null | Club member ID |
homeClub.membershipType | string | null | Full/associate/junior |
πΎ Tennis Profileβ
| Field | Type | Default | Description |
|---|---|---|---|
skillLevel | string | "beginner" | beginner/intermediate/advanced/pro |
dominantHand | string | "right" | right/left |
yearsPlaying | number | null | null | Years of experience (set during onboarding) |
playingFrequency | number | null | null | Sessions per week (set during onboarding) |
playingStyle | string | null | null | Baseline / All-Court / Serve-and-Volley / etc. |
hasCoach | boolean | null | null | Currently has a coach |
primaryGoals | string[] | [] | Selected goals from onboarding step 4 |
π Performance (AI-Updated)β
| Field | Type | Default | Description |
|---|---|---|---|
overallRating | number | 0 | 0-100 composite score |
skillRatings.serve | number | 0 | Serve rating 0-100 |
skillRatings.forehand | number | 0 | Forehand rating 0-100 |
skillRatings.backhand | number | 0 | Backhand rating 0-100 |
skillRatings.footwork | number | 0 | Footwork rating 0-100 |
statistics.totalSessions | number | 0 | Total analysis sessions |
statistics.totalHours | number | 0 | Hours analyzed |
lastUpdated | timestamp | auto | Last AI update |
π³ Subscriptionβ
| Field | Type | Default | Description |
|---|---|---|---|
plan | string | "trial" | trial/free/pro/enterprise |
startDate | timestamp | auto | Plan start date |
trialStartedAt | timestamp | auto | Trial start (same as signup) |
trialEndsAt | timestamp | now + 14 days | Trial expiry (set by Cloud Function) |
autoRenew | boolean | false | Auto-renewal enabled |
creditsUsed | number | 0 | Credits consumed |
creditsLimit | number | 25 | Trial credit limit |
analysesLimit | number | 10 | Trial analysis limit |
analysesUsed | number | 0 | Analyses this period |
:::info Top-level mirrors
plan and trialEndsAt are also written at the document root (e.g., users/{uid}.plan). These top-level copies exist for fast reads β the client reads them directly without descending into the subscription sub-object.
:::
π Privacyβ
| Field | Type | Default | Description |
|---|---|---|---|
consentGiven | timestamp | auto | Consent timestamp |
consentVersion | string | "1.0" | Terms version |
marketingConsent | boolean | false | Marketing emails |
analyticsConsent | boolean | true | Usage analytics |
dataRetentionDays | number | 365 | Data retention period |
π Raw JSON Templateβ
Click to expand full JSON schema
{
"_config": {
"schemaVersion": "1.0.0",
"timestampFields": [
"performance.lastUpdated",
"subscription.startDate",
"privacy.consentGiven"
]
},
"schemaVersion": "1.0.0",
"personalInfo": {
"firstName": "",
"lastName": "",
"dateOfBirth": null,
"gender": null,
"phoneNumber": null,
"nationality": null
},
"location": {
"country": null,
"city": null,
"timezone": null,
"homeClub": {
"name": null,
"membershipId": null,
"joinDate": null,
"membershipType": null
}
},
"coaching": {
"currentCoach": {
"name": null,
"email": null,
"phone": null,
"club": null,
"certification": null,
"startDate": null
},
"coachingHistory": [],
"preferredCoachingStyle": null,
"lessonFrequency": null
},
"tennisProfile": {
"skillLevel": "beginner",
"dominantHand": "right",
"yearsPlaying": null,
"playingFrequency": null,
"playingStyle": null,
"hasCoach": null,
"primaryGoals": []
},
"onboarding": {
"completed": false,
"step": 0,
"startedAt": "{{timestamp}}",
"completedAt": null
},
"performance": {
"overallRating": 0,
"skillRatings": {
"serve": 0,
"forehand": 0,
"backhand": 0,
"footwork": 0
},
"statistics": {
"totalSessions": 0,
"totalHours": 0,
"matchesPlayed": 0,
"winRate": 0
},
"lastUpdated": "{{timestamp}}"
},
"analysisUsage": {
"totalAnalyses": 0,
"analysesThisMonth": 0,
"analysesThisYear": 0,
"analysisTypes": {
"fullMatch": 0,
"practiceDrills": 0,
"serveAnalysis": 0,
"strokeTechnique": 0
}
},
"subscription": {
"plan": "trial",
"startDate": "{{timestamp}}",
"trialStartedAt": "{{timestamp}}",
"trialEndsAt": "<Timestamp: now + 14 days>",
"autoRenew": false,
"creditsUsed": 0,
"creditsLimit": 25,
"analysesLimit": 10,
"analysesUsed": 0
},
"privacy": {
"consentGiven": "{{timestamp}}",
"consentVersion": "1.0",
"marketingConsent": false,
"analyticsConsent": true,
"dataRetentionDays": 365
},
"isActive": true
}
ποΈ Full Architectureβ
πΎ Firestore Schemaβ
Coach Mode (
coaches,coachLinks,coachNotes) β server-write only via the coach callables; rules grant party-read and forbid client writes. See the Coach Mode functions above and the frontendPRD_COACH_ACCOUNT.md.
π Deploymentβ
This is a standalone repo. Install with pnpm, build (TypeScript), then deploy:
cd acesense-auth-function
pnpm install
pnpm build # tsc
firebase deploy --only functions
View Logsβ
firebase functions:log --only createUserDocument
π§ Configurationβ
| Setting | Value |
|---|---|
| Region | europe-west1 |
| Runtime | Node.js 22 |
| TypeScript | 5.1.6 |
| Schema Version | 1.0.0 |
π Firestore Security Rulesβ
The app requires these security rules for client-side access:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users can read/write their own document
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Jobs - authenticated users can access their own jobs
match /jobs/{jobId} {
allow read: if request.auth != null;
allow write: if request.auth != null;
}
// Sessions - authenticated users can access their own sessions
match /sessions/{sessionId} {
allow read: if request.auth != null;
allow write: if request.auth != null;
}
// Coach Mode β party-read, server-write only (the coach callables write
// via the Admin SDK and bypass these rules). Access is checked against the
// document's coachId/playerId fields, never the id, so it can't be spoofed.
match /coaches/{coachId} {
allow read: if request.auth != null &&
(request.auth.uid == coachId); // + admins
allow create, update, delete: if false; // becomeCoach
}
match /coachLinks/{linkId} {
allow read: if request.auth != null &&
(request.auth.uid == resource.data.coachId ||
request.auth.uid == resource.data.playerId);
allow create, update, delete: if false; // link callables
}
match /coachNotes/{noteId} {
allow read: if request.auth != null &&
(request.auth.uid == resource.data.coachId ||
request.auth.uid == resource.data.playerId);
allow create, update, delete: if false; // addCoachNote
}
}
}
[!NOTE] Cloud Functions using Admin SDK bypass these rules automatically. The snippet above is illustrative; the canonical rules live in
acesense-frontend/firestore.rules(admin allowlist, field whitelists, etc.).
π Required Indexesβ
The app requires a composite index for job queries:
| Collection | Fields | Order |
|---|---|---|
jobs | userId, createdAt | ASC, DESC |
Create via Firebase Console β Firestore β Indexes, or the link in the error message.
π IAM Setupβ
The Cloud Functions service account needs proper permissions:
| Service Account | Required Role |
|---|---|
acesense-prod@appspot.gserviceaccount.com | Cloud Datastore User |
{project-number}-compute@developer.gserviceaccount.com | Editor |
To add permissions:
- Go to IAM & Admin
- Click Grant Access
- Add the service account and required role