Skip to main content

⚑ 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

RepositoryCommitRegionStatus
acesense-auth-function281f281europe-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​

FunctionTriggerVersionPurpose
createUserDocumentauth.user().onCreatev1Creates user profile
deleteUserDocumentauth.user().onDeletev1Cascade-deletes ALL account data (F-9): profile, jobs, coach links/notes, API keys, and Storage `videos
markEmailVerifiedhttps.onCallv1Flips verified flag + fires welcome email once
exportUserDatahttps.onCallv1Builds a zip (profile + sessions + PDF summaries), emails signed link
requestAccountDeletionhttps.onCallv1Sends farewell email and deletes Auth user (Firestore cleanup via deleteUserDocument)
processVideoOnUploadstorage.onObjectFinalized on videos/**v2Generates a download URL for the uploaded video and hands it to the GPU router (Lambda)
onResultUploadedstorage.onObjectFinalized on results/**v2Looks up the job by matching resultPath, marks it done, stores resultUrl
analyzeVideoOnProcessingFirestore jobs/{jobId} onDocumentUpdated (uploaded→processing edge)v2Vertex 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
mergeAnalysisReportsFirestore jobs/{jobId} onDocumentUpdated (both analyses terminal)v2Deterministic 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
requestUploadPathhttps.onCallv1Validates metadata, reserves quota, creates the jobs/{id} doc, returns { uploadPath, resultPath, jobId, sessionId, expiresAt, quota } (single-file upload)
reportUploadFailurehttps.onCallv1Client-reported upload failure β€” fails the job and refunds reserved cost for API jobs
mergeChunkResultshttps.onCallv1Legacy β€” merges per-chunk analyses. The client no longer chunks (single-file upload); retained for backward compatibility
sendPasswordResetCustomhttps.onCallv1Sends a branded password-reset email
deleteSessionhttps.onCallv1Owner-scoped per-session erase: job doc + the session's Storage folders (video, result JSON, assets zip, annotated video, shot clips)
reconcileStuckJobsCloud Scheduler (hourly)v2Fails jobs stuck in pending/uploaded (>2 h) or processing (>6 h); releases the leaked concurrency slot, refunds API-job cost
healthhttps.onRequestv2Health check β€” returns { status, region, timestamp, version }
onFeedbackCreatedFirestore feedback/{id} onDocumentCreatedv2Emails admin@acesense.io with the user's app feedback (text, email, UID)
onDocsFeedbackCreatedFirestore docs_feedback/{id} onDocumentCreatedv2Emails admin@acesense.io for docs feedback (πŸ‘Ž always, πŸ‘ only when a note is attached)

Public API / agent-commerce surface (api/, mcp/)​

FunctionTriggerVersionPurpose
apiServerhttps.onRequestv2Public REST API behind api.acesense.io/v1/** (fronted by acesense-api-hosting)
mcpServerhttps.onRequestv2JSON-RPC MCP server at api.acesense.io/mcp
createApiKey / listApiKeys / revokeApiKeyhttps.onCallv2API key lifecycle for agent-commerce clients
verifyGooglePlayPurchase / verifyApplePurchasehttps.onCallv2Verifies direct store purchases and mirrors entitlement state β†’ users/{uid}
googlePlayBillingNotificationPub/Subv2Reconciles Google Play subscription lifecycle events
appStoreServerNotificationhttps.onRequestv2Reconciles App Store Server Notifications V2
syncTierToPlanFirestore users/{uid} onDocumentWrittenv2Mirrors new tier field β†’ legacy plan for backward-compatible consumers

Admin-only callables (admin/) β€” gated by requireAdmin (Firestore allowlist)​

FunctionTriggerPurpose
adminRetryJobhttps.onCallRe-dispatch a failed job
adminSetUserPlanhttps.onCallOverride a user's plan/tier
adminSetUserSuspendedhttps.onCallSuspend / unsuspend a user
adminAdjustApiKeyBalancehttps.onCallCredit/debit an API key balance
adminRevokeSignedUrlhttps.onCallRevoke an outstanding signed URL
bootstrapAdminClaimshttps.onCallBootstrap admin custom claims
adminListStoragehttps.onCallServer-side (Admin SDK) one-level bucket browse β€” storage.rules grant admins no client access
adminGetStorageUrlhttps.onCall15-min signed read URL; writes a storage_preview/storage_download audit row
adminDeleteStorageObjecthttps.onCallAtomic 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.

FunctionTriggerVersionPurpose
becomeCoachhttps.onCallv2Grants the role: coach claim + upserts coaches/{uid}
createCoachLinkhttps.onCallv2Starts a pending link (two-sided consent); seat-checked (free ≀ 3)
respondToCoachLinkhttps.onCallv2accept (non-initiator) / decline / revoke (either party)
listCoachLinkshttps.onCallv2{ asCoach[], asPlayer[] } for the caller
addCoachNotehttps.onCallv2Coach writes a link-gated, append-only feedback note
listCoachNoteshttps.onCallv2A (coach, player) pair's notes; either party reads

πŸ” Auth Functions​

The acesense-auth-function repository handles user document lifecycle.

Functions​

FunctionTriggerDescription
createUserDocumentauth.user().onCreateCreates user profile from JSON template, enqueues welcome email when account is pre-verified
deleteUserDocumentauth.user().onDeleteCascade-deletes the account data (profile, jobs, coach data, API keys, media) β€” see GDPR section
markEmailVerifiedhttps.onCallRe-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:

    1. Collects the user's Firestore doc (users/{uid})
    2. Fetches all jobs with userId == uid created within the last 30 days
    3. For each job, downloads resultPath (analysis JSON) and generates a server-side pdfkit summary PDF (text + stats, no charts)
    4. Streams a zip to exports/{uid}/export-{ts}.zip on Storage using archiver, attaching a Firebase download token in object metadata
    5. Enqueues an email via the Trigger Email extension with the signed URL
  • Zip contents:

    README.md
    profile.json
    sessions.json
    sessions/{sessionId}/analysis.json
    sessions/{sessionId}/report.pdf
  • Videos: intentionally excluded (hundreds of MB each). README directs users to email info@acesense.io with the session ID to request specific footage. Match video is retained for 30 days on a rolling window.

requestAccountDeletion​

  • Trigger: https.onCall
  • Flow:
    1. Enqueues a farewell email (accountDeletedEmail) via the same extension
    2. Calls admin.auth().deleteUser(uid) β€” this fires the deleteUserDocument onDelete trigger, which now cascade-deletes the whole account (audit F-9): users/{uid}, all jobs, coach links/notes/profile, API keys (recursive), and Storage videos|results/{uid}/** (user + API paths). Immutable audit trails and sent mail docs are retained deliberately (pseudonymous; flagged for legal review)
  • 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:

ParamValue
Mail collectionmail
Authentication typeUsernamePassword
SMTP connection URIsmtps://resend:RESEND_API_KEY@smtp.resend.com:465 (or any SMTP provider)
Default FROMAceSense <info@acesense.io>
Default REPLY-TOinfo@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 onDocumentCreated on feedback/{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 text is empty.

onDocsFeedbackCreated​

  • Trigger: v2 onDocumentCreated on docs_feedback/{id}
  • Source: feedback/index.ts (acesense-auth-function)
  • Source surface: Docusaurus <DocFeedback /> widget β†’ docs docsServer POST /__/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):
    1. Magic-byte gate β€” verifies the container matches the extension
    2. Mints a short-lived signed URL (≀ 30 min TTL, RUNPOD_SIGNED_URL_TTL_MS)
    3. ffprobe gate β€” validates duration/codec/resolution bounds
    4. Cloud Vision first-frame moderation
    5. Budget gate (assertJobBudget) before dispatch
    6. Dispatches to the GPU router with the signed video_url + derived result_path. RunPod was removed 2026-08-16; the Lambda pull-worker fleet is the only provider
    7. 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 } β€” quota lets the client render an "X/Y today" badge. Job doc is written with isChunked: 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:
    1. Triggers on results/*_combined.json file uploads
    2. Queries Firestore for job matching resultPath
    3. Updates job status to "done" with completion timestamp
    4. Generates and stores the resultUrl for frontend access

Architecture​


πŸ” Auth Architecture​

User Document Schema​

The user schema is data-driven via templates/user-template.json.

πŸ“‹ Schema Overview​

SectionFieldsDescription
Coreuid, email, displayName, photoURLFrom Firebase Auth
Plan (top-level)plan, trialEndsAt, jobCountFast-read trial/subscription state
Personal Infoname, DOB, gender, phone, nationalityUser profile details
Locationcountry, city, timezone, homeClubGeographic & club info
CoachingcurrentCoach, history, preferencesCoach relationships
Tennis ProfileskillLevel, dominantHand, playingStyle, goalsPlaying background (filled by onboarding)
Onboardingcompleted, step, startedAt, completedAt6-step onboarding state
PerformanceoverallRating, skillRatings, statisticsAI-calculated metrics
Analysis Usagetotals, monthly, types breakdownUsage tracking
Subscriptionplan, credits, trial dates, limitsBilling & limits
Privacyconsent, marketing, analyticsGDPR compliance

πŸ‘€ Personal Info​

FieldTypeDefaultDescription
firstNamestring""Parsed from displayName
lastNamestring""Parsed from displayName
dateOfBirthtimestampnullUser's birth date
genderstringnullmale/female/other/prefer_not_to_say
phoneNumberstringnullContact number
nationalitystringnullCountry code (ISO)

πŸ“ Location​

FieldTypeDefaultDescription
countrystringnullCountry of residence
citystringnullCity of residence
timezonestringnullIANA timezone
homeClub.namestringnullTennis club name
homeClub.membershipIdstringnullClub member ID
homeClub.membershipTypestringnullFull/associate/junior

🎾 Tennis Profile​

FieldTypeDefaultDescription
skillLevelstring"beginner"beginner/intermediate/advanced/pro
dominantHandstring"right"right/left
yearsPlayingnumber | nullnullYears of experience (set during onboarding)
playingFrequencynumber | nullnullSessions per week (set during onboarding)
playingStylestring | nullnullBaseline / All-Court / Serve-and-Volley / etc.
hasCoachboolean | nullnullCurrently has a coach
primaryGoalsstring[][]Selected goals from onboarding step 4

πŸ“Š Performance (AI-Updated)​

FieldTypeDefaultDescription
overallRatingnumber00-100 composite score
skillRatings.servenumber0Serve rating 0-100
skillRatings.forehandnumber0Forehand rating 0-100
skillRatings.backhandnumber0Backhand rating 0-100
skillRatings.footworknumber0Footwork rating 0-100
statistics.totalSessionsnumber0Total analysis sessions
statistics.totalHoursnumber0Hours analyzed
lastUpdatedtimestampautoLast AI update

πŸ’³ Subscription​

FieldTypeDefaultDescription
planstring"trial"trial/free/pro/enterprise
startDatetimestampautoPlan start date
trialStartedAttimestampautoTrial start (same as signup)
trialEndsAttimestampnow + 14 daysTrial expiry (set by Cloud Function)
autoRenewbooleanfalseAuto-renewal enabled
creditsUsednumber0Credits consumed
creditsLimitnumber25Trial credit limit
analysesLimitnumber10Trial analysis limit
analysesUsednumber0Analyses 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​

FieldTypeDefaultDescription
consentGiventimestampautoConsent timestamp
consentVersionstring"1.0"Terms version
marketingConsentbooleanfalseMarketing emails
analyticsConsentbooleantrueUsage analytics
dataRetentionDaysnumber365Data 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 frontend PRD_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​

SettingValue
Regioneurope-west1
RuntimeNode.js 22
TypeScript5.1.6
Schema Version1.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:

CollectionFieldsOrder
jobsuserId, createdAtASC, 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 AccountRequired Role
acesense-prod@appspot.gserviceaccount.comCloud Datastore User
{project-number}-compute@developer.gserviceaccount.comEditor

To add permissions:

  1. Go to IAM & Admin
  2. Click Grant Access
  3. Add the service account and required role