feat: Cloud Functions — leaveCoupleCallable, onUserDelete cascade (batch v0.2.8)

- Add leaveCoupleCallable: HTTPS callable that atomically unlinks couple via Admin SDK (clears both user coupleIds, recursiveDelete couple doc)
- Add onUserDelete: Auth deletion trigger that cascades cleanup — unpairs partner, sends FCM notification, deletes Storage objects, recursiveDelete user doc
- Replace client-side batch leaveCouple with callable invocation (Firestore rules prevent cross-user writes)
- Remove CoupleRepository/UserRepository from DeleteAccountViewModel — cleanup now handled by onUserDelete trigger
- Wire new functions into index.ts exports
This commit is contained in:
null 2026-06-19 20:04:18 -05:00
parent cbaa68ef2e
commit 6828be72fc
12 changed files with 450 additions and 41 deletions

View File

@ -5,14 +5,19 @@ import app.closer.domain.model.Couple
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import com.google.firebase.functions.FirebaseFunctions
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@Singleton @Singleton
class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFirestore) { class FirestoreCoupleDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val functions: FirebaseFunctions
) {
private fun coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId) private fun coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId)
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid) private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
@ -111,32 +116,10 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
} }
} }
suspend fun leaveCouple(userId: String) { suspend fun leaveCouple() {
val userSnap = suspendCancellableCoroutine<DocumentSnapshot> { cont -> // The client cannot batch-update the partner's user doc (rules deny cross-user writes).
userRef(userId).get() // Delegate to the leaveCoupleCallable Cloud Function which uses the Admin SDK.
.addOnSuccessListener { cont.resume(it) } functions.getHttpsCallable("leaveCoupleCallable").call().await()
.addOnFailureListener { cont.resumeWithException(it) }
}
val coupleId = userSnap.getString("coupleId") ?: return
val coupleSnap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
@Suppress("UNCHECKED_CAST")
val allUserIds = (coupleSnap.get("userIds") as? List<String>) ?: listOf(userId)
val partnerId = allUserIds.firstOrNull { it != userId }
suspendCancellableCoroutine<Unit> { cont ->
val batch = db.batch()
allUserIds.forEach { uid ->
batch.update(userRef(uid), "coupleId", null)
}
batch.commit()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View File

@ -62,7 +62,7 @@ class CoupleRepositoryImpl @Inject constructor(
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching { override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
val coupleId = userDataSource.getUser(userId)?.coupleId val coupleId = userDataSource.getUser(userId)?.coupleId
coupleDataSource.leaveCouple(userId) coupleDataSource.leaveCouple()
if (coupleId != null) encryptionManager.deleteKeyset(coupleId) if (coupleId != null) encryptionManager.deleteKeyset(coupleId)
} }

View File

@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -77,9 +75,7 @@ data class DeleteAccountUiState(
@HiltViewModel @HiltViewModel
class DeleteAccountViewModel @Inject constructor( class DeleteAccountViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DeleteAccountUiState()) private val _uiState = MutableStateFlow(DeleteAccountUiState())
@ -95,16 +91,15 @@ class DeleteAccountViewModel @Inject constructor(
fun confirmDelete() { fun confirmDelete() {
if (!uiState.value.canDelete) return if (!uiState.value.canDelete) return
val uid = authRepository.currentUserId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) } _uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) }
// Delete auth account FIRST so a later cleanup failure never leaves the user // Delete the auth account first. The onUserDelete Cloud Function fires
// with data gone but an active account. If this throws RecentLoginRequired // asynchronously and handles all cascading cleanup: couple unpair,
// we stop before touching any data. // partner notification, Firestore subcollections, and Storage.
// Doing auth deletion first means a RecentLoginRequired failure stops
// us before any data is touched (prevents the half-deleted state).
authRepository.deleteAccount() authRepository.deleteAccount()
.onSuccess { .onSuccess {
runCatching { coupleRepository.leaveCouple(uid) }
runCatching { userRepository.deleteUserData(uid) }
_uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) } _uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) }
} }
.onFailure { e -> .onFailure { e ->

View File

@ -0,0 +1,88 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.leaveCoupleCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* HTTPS callable that atomically unlinks a couple.
*
* The client cannot do this directly because Firestore rules prevent a user
* from writing the partner's user document. The Admin SDK bypasses those rules.
*
* Steps:
* 1. Verify the caller is a member of their current couple.
* 2. Clear coupleId on both user docs (batch atomic).
* 3. Recursively delete the couple doc and all its subcollections.
*
* The existing onCoupleLeave Firestore trigger fires after step 2 and handles
* partner notification, so we don't duplicate that here.
*/
exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
var _a, _b, _c, _d;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.');
}
const db = admin.firestore();
const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) {
// Already unpaired — idempotent success.
return { success: true };
}
const coupleRef = db.collection('couples').doc(coupleId);
const coupleDoc = await coupleRef.get();
if (!coupleDoc.exists) {
// Couple doc gone — just clear caller's field.
await db.collection('users').doc(callerId).update({ coupleId: null });
return { success: true };
}
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
if (!userIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.');
}
// Clear coupleId for all members atomically.
const batch = db.batch();
for (const uid of userIds) {
batch.update(db.collection('users').doc(uid), { coupleId: null });
}
await batch.commit();
// Recursively delete the couple document and every subcollection beneath it.
await db.recursiveDelete(coupleRef);
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`);
return { success: true };
});
//# sourceMappingURL=leaveCoupleCallable.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;;;;;;;GAaG;AACU,QAAA,mBAAmB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACjF,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/E,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA8B,CAAA;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,yCAAyC;QACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACxD,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;IAEvC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,+CAA+C;QAC/C,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;QACrE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAChC,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,mBAAmB,EAAE,8BAA8B,CAAC,CAAA;IAC3F,CAAC;IAED,6CAA6C;IAC7C,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,EAAE,CAAA;IACxB,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACnE,CAAC;IACD,MAAM,KAAK,CAAC,MAAM,EAAE,CAAA;IAEpB,6EAA6E;IAC7E,MAAM,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAA;IAEnC,OAAO,CAAC,GAAG,CAAC,8BAA8B,QAAQ,gBAAgB,QAAQ,EAAE,CAAC,CAAA;IAC7E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC,CAAC,CAAA"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.health = exports.onGameSessionUpdate = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // Initialize the Admin SDK once for every function in this codebase.
@ -63,6 +63,10 @@ var onAnswerWritten_1 = require("./questions/onAnswerWritten");
Object.defineProperty(exports, "onAnswerWritten", { enumerable: true, get: function () { return onAnswerWritten_1.onAnswerWritten; } }); Object.defineProperty(exports, "onAnswerWritten", { enumerable: true, get: function () { return onAnswerWritten_1.onAnswerWritten; } });
var onCoupleLeave_1 = require("./couples/onCoupleLeave"); var onCoupleLeave_1 = require("./couples/onCoupleLeave");
Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } }); Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } });
var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable");
Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } });
var onUserDelete_1 = require("./users/onUserDelete");
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } }); Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } });
/** /**

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"}

145
functions/dist/users/onUserDelete.js vendored Normal file
View File

@ -0,0 +1,145 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onUserDelete = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* Auth deletion trigger cascades account deletion server-side.
*
* Fires when a Firebase Auth user is deleted (via client deleteAccount() or
* Admin SDK). The Admin SDK bypasses Firestore rules, so this function can
* reach everything the client cannot.
*
* Steps (all best-effort after the auth account is already gone):
* 1. If the user was in a couple:
* a. Clear the partner's coupleId.
* b. Send the partner an FCM push notification.
* c. Recursively delete the couple doc + all subcollections.
* 2. Delete all Storage objects under users/{uid}/ (profile photo etc.).
* 3. Recursively delete the user doc + all subcollections
* (entitlements, fcmTokens, notification_queue).
*/
exports.onUserDelete = functions.auth.user().onDelete(async (user) => {
var _a, _b, _c, _d;
const uid = user.uid;
const db = admin.firestore();
// ── 1. Couple unpair ──────────────────────────────────────────────────────
const userDocRef = db.collection('users').doc(uid);
const userDoc = await userDocRef.get();
const coupleId = userDoc.exists
? (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.coupleId
: undefined;
if (coupleId) {
const coupleRef = db.collection('couples').doc(coupleId);
const coupleDoc = await coupleRef.get();
if (coupleDoc.exists) {
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
const partnerId = userIds.find((id) => id !== uid);
if (partnerId) {
const partnerRef = db.collection('users').doc(partnerId);
const partnerDoc = await partnerRef.get();
// Only act if the partner is still recorded in this couple.
if (partnerDoc.exists && ((_d = partnerDoc.data()) === null || _d === void 0 ? void 0 : _d.coupleId) === coupleId) {
// Clear partner's coupleId first so the onCoupleLeave trigger does
// not fire a second time (it would find no partner left to notify).
await partnerRef.update({ coupleId: null });
// Send push notification to the partner.
await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
console.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed:`, err);
});
}
}
// Recursively delete the couple doc + all subcollections.
await db.recursiveDelete(coupleRef);
}
}
// ── 2. Storage cleanup ────────────────────────────────────────────────────
try {
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` });
}
catch (err) {
console.warn(`[onUserDelete] Storage cleanup failed for ${uid}:`, err);
}
// ── 3. User doc + subcollections ──────────────────────────────────────────
await db.recursiveDelete(userDocRef);
console.log(`[onUserDelete] completed cleanup for user ${uid}`);
});
async function notifyPartner(partnerId, partnerDoc, db) {
var _a;
const messaging = admin.messaging();
// Collect FCM tokens (legacy field + fcmTokens subcollection).
const tokens = [];
const legacyToken = (_a = partnerDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get();
tokenSnap.docs.forEach((doc) => {
var _a;
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t);
}
});
if (tokens.length === 0)
return;
// Write an in-app notification record.
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: 'partner_deleted_account',
title: 'Your partner deleted their account',
body: 'You are no longer paired. Tap to create a new invite.',
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({
token,
notification: {
title: 'Your partner deleted their account',
body: 'You are no longer paired. Tap to create a new invite.',
},
data: { type: 'partner_deleted_account' },
})));
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[onUserDelete] FCM send to token ${tokens[i]} failed:`, result.reason);
}
});
}
//# sourceMappingURL=onUserDelete.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":"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"}

View File

@ -0,0 +1,59 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* HTTPS callable that atomically unlinks a couple.
*
* The client cannot do this directly because Firestore rules prevent a user
* from writing the partner's user document. The Admin SDK bypasses those rules.
*
* Steps:
* 1. Verify the caller is a member of their current couple.
* 2. Clear coupleId on both user docs (batch atomic).
* 3. Recursively delete the couple doc and all its subcollections.
*
* The existing onCoupleLeave Firestore trigger fires after step 2 and handles
* partner notification, so we don't duplicate that here.
*/
export const leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.')
}
const db = admin.firestore()
const userDoc = await db.collection('users').doc(callerId).get()
const coupleId = userDoc.data()?.coupleId as string | undefined
if (!coupleId) {
// Already unpaired — idempotent success.
return { success: true }
}
const coupleRef = db.collection('couples').doc(coupleId)
const coupleDoc = await coupleRef.get()
if (!coupleDoc.exists) {
// Couple doc gone — just clear caller's field.
await db.collection('users').doc(callerId).update({ coupleId: null })
return { success: true }
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.')
}
// Clear coupleId for all members atomically.
const batch = db.batch()
for (const uid of userIds) {
batch.update(db.collection('users').doc(uid), { coupleId: null })
}
await batch.commit()
// Recursively delete the couple document and every subcollection beneath it.
await db.recursiveDelete(coupleRef)
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)
return { success: true }
})

View File

@ -26,6 +26,8 @@ export {
} from './questions/assignDailyQuestion' } from './questions/assignDailyQuestion'
export { onAnswerWritten } from './questions/onAnswerWritten' export { onAnswerWritten } from './questions/onAnswerWritten'
export { onCoupleLeave } from './couples/onCoupleLeave' export { onCoupleLeave } from './couples/onCoupleLeave'
export { leaveCoupleCallable } from './couples/leaveCoupleCallable'
export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate } from './games/onGameSessionUpdate' export { onGameSessionUpdate } from './games/onGameSessionUpdate'
/** /**

View File

@ -0,0 +1,131 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
/**
* Auth deletion trigger cascades account deletion server-side.
*
* Fires when a Firebase Auth user is deleted (via client deleteAccount() or
* Admin SDK). The Admin SDK bypasses Firestore rules, so this function can
* reach everything the client cannot.
*
* Steps (all best-effort after the auth account is already gone):
* 1. If the user was in a couple:
* a. Clear the partner's coupleId.
* b. Send the partner an FCM push notification.
* c. Recursively delete the couple doc + all subcollections.
* 2. Delete all Storage objects under users/{uid}/ (profile photo etc.).
* 3. Recursively delete the user doc + all subcollections
* (entitlements, fcmTokens, notification_queue).
*/
export const onUserDelete = functions.auth.user().onDelete(async (user) => {
const uid = user.uid
const db = admin.firestore()
// ── 1. Couple unpair ──────────────────────────────────────────────────────
const userDocRef = db.collection('users').doc(uid)
const userDoc = await userDocRef.get()
const coupleId = userDoc.exists
? (userDoc.data()?.coupleId as string | undefined)
: undefined
if (coupleId) {
const coupleRef = db.collection('couples').doc(coupleId)
const coupleDoc = await coupleRef.get()
if (coupleDoc.exists) {
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== uid)
if (partnerId) {
const partnerRef = db.collection('users').doc(partnerId)
const partnerDoc = await partnerRef.get()
// Only act if the partner is still recorded in this couple.
if (partnerDoc.exists && partnerDoc.data()?.coupleId === coupleId) {
// Clear partner's coupleId first so the onCoupleLeave trigger does
// not fire a second time (it would find no partner left to notify).
await partnerRef.update({ coupleId: null })
// Send push notification to the partner.
await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
console.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed:`, err)
})
}
}
// Recursively delete the couple doc + all subcollections.
await db.recursiveDelete(coupleRef)
}
}
// ── 2. Storage cleanup ────────────────────────────────────────────────────
try {
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` })
} catch (err) {
console.warn(`[onUserDelete] Storage cleanup failed for ${uid}:`, err)
}
// ── 3. User doc + subcollections ──────────────────────────────────────────
await db.recursiveDelete(userDocRef)
console.log(`[onUserDelete] completed cleanup for user ${uid}`)
})
async function notifyPartner(
partnerId: string,
partnerDoc: admin.firestore.DocumentSnapshot,
db: admin.firestore.Firestore
): Promise<void> {
const messaging = admin.messaging()
// Collect FCM tokens (legacy field + fcmTokens subcollection).
const tokens: string[] = []
const legacyToken = partnerDoc.data()?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get()
tokenSnap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t)
}
})
if (tokens.length === 0) return
// Write an in-app notification record.
await db
.collection('users')
.doc(partnerId)
.collection('notification_queue')
.add({
type: 'partner_deleted_account',
title: 'Your partner deleted their account',
body: 'You are no longer paired. Tap to create a new invite.',
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
const sendResults = await Promise.allSettled(
tokens.map((token) =>
messaging.send({
token,
notification: {
title: 'Your partner deleted their account',
body: 'You are no longer paired. Tap to create a new invite.',
},
data: { type: 'partner_deleted_account' },
})
)
)
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[onUserDelete] FCM send to token ${tokens[i]} failed:`, result.reason)
}
})
}