diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt index 88805377..621a92e8 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt @@ -5,14 +5,19 @@ import app.closer.domain.model.Couple import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions +import com.google.firebase.functions.FirebaseFunctions import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @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 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) { - val userSnap = suspendCancellableCoroutine { cont -> - userRef(userId).get() - .addOnSuccessListener { cont.resume(it) } - .addOnFailureListener { cont.resumeWithException(it) } - } - val coupleId = userSnap.getString("coupleId") ?: return - val coupleSnap = suspendCancellableCoroutine { cont -> - coupleRef(coupleId).get() - .addOnSuccessListener { cont.resume(it) } - .addOnFailureListener { cont.resumeWithException(it) } - } - @Suppress("UNCHECKED_CAST") - val allUserIds = (coupleSnap.get("userIds") as? List) ?: listOf(userId) - val partnerId = allUserIds.firstOrNull { it != userId } - - suspendCancellableCoroutine { cont -> - val batch = db.batch() - allUserIds.forEach { uid -> - batch.update(userRef(uid), "coupleId", null) - } - batch.commit() - .addOnSuccessListener { cont.resume(Unit) } - .addOnFailureListener { cont.resumeWithException(it) } - } - + suspend fun leaveCouple() { + // The client cannot batch-update the partner's user doc (rules deny cross-user writes). + // Delegate to the leaveCoupleCallable Cloud Function which uses the Admin SDK. + functions.getHttpsCallable("leaveCoupleCallable").call().await() } @Suppress("UNCHECKED_CAST") diff --git a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt index cac18e77..d03eab7a 100644 --- a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt @@ -62,7 +62,7 @@ class CoupleRepositoryImpl @Inject constructor( override suspend fun leaveCouple(userId: String): Result = runCatching { val coupleId = userDataSource.getUser(userId)?.coupleId - coupleDataSource.leaveCouple(userId) + coupleDataSource.leaveCouple() if (coupleId != null) encryptionManager.deleteKeyset(coupleId) } diff --git a/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt b/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt index 37c660a2..21eab2fc 100644 --- a/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt @@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.core.navigation.AppRoute 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 javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -77,9 +75,7 @@ data class DeleteAccountUiState( @HiltViewModel class DeleteAccountViewModel @Inject constructor( - private val authRepository: AuthRepository, - private val coupleRepository: CoupleRepository, - private val userRepository: UserRepository + private val authRepository: AuthRepository ) : ViewModel() { private val _uiState = MutableStateFlow(DeleteAccountUiState()) @@ -95,16 +91,15 @@ class DeleteAccountViewModel @Inject constructor( fun confirmDelete() { if (!uiState.value.canDelete) return - val uid = authRepository.currentUserId ?: return viewModelScope.launch { _uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) } - // Delete auth account FIRST so a later cleanup failure never leaves the user - // with data gone but an active account. If this throws RecentLoginRequired - // we stop before touching any data. + // Delete the auth account first. The onUserDelete Cloud Function fires + // asynchronously and handles all cascading cleanup: couple unpair, + // 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() .onSuccess { - runCatching { coupleRepository.leaveCouple(uid) } - runCatching { userRepository.deleteUserData(uid) } _uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) } } .onFailure { e -> diff --git a/functions/dist/couples/leaveCoupleCallable.js b/functions/dist/couples/leaveCoupleCallable.js new file mode 100644 index 00000000..f2f3af00 --- /dev/null +++ b/functions/dist/couples/leaveCoupleCallable.js @@ -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 \ No newline at end of file diff --git a/functions/dist/couples/leaveCoupleCallable.js.map b/functions/dist/couples/leaveCoupleCallable.js.map new file mode 100644 index 00000000..a0a799e8 --- /dev/null +++ b/functions/dist/couples/leaveCoupleCallable.js.map @@ -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"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index f79aaac7..c975810a 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); 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 admin = __importStar(require("firebase-admin")); // 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; } }); var onCoupleLeave_1 = require("./couples/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"); Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } }); /** diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 9af30036..16e7adce 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/functions/dist/users/onUserDelete.js b/functions/dist/users/onUserDelete.js new file mode 100644 index 00000000..99555b6a --- /dev/null +++ b/functions/dist/users/onUserDelete.js @@ -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 \ No newline at end of file diff --git a/functions/dist/users/onUserDelete.js.map b/functions/dist/users/onUserDelete.js.map new file mode 100644 index 00000000..6e67d298 --- /dev/null +++ b/functions/dist/users/onUserDelete.js.map @@ -0,0 +1 @@ +{"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;;;;;;;;;GAeG;AACU,QAAA,YAAY,GAAG,SAAS,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;;IACxE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAA;IACpB,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,6EAA6E;IAE7E,MAAM,UAAU,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAClD,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,GAAG,EAAE,CAAA;IACtC,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM;QAC7B,CAAC,CAAE,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA+B;QAClD,CAAC,CAAC,SAAS,CAAA;IAEb,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;QACxD,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;QAEvC,IAAI,SAAS,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;YAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,KAAK,GAAG,CAAC,CAAA;YAElD,IAAI,SAAS,EAAE,CAAC;gBACd,MAAM,UAAU,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;gBACxD,MAAM,UAAU,GAAG,MAAM,UAAU,CAAC,GAAG,EAAE,CAAA;gBAEzC,4DAA4D;gBAC5D,IAAI,UAAU,CAAC,MAAM,IAAI,CAAA,MAAA,UAAU,CAAC,IAAI,EAAE,0CAAE,QAAQ,MAAK,QAAQ,EAAE,CAAC;oBAClE,mEAAmE;oBACnE,oEAAoE;oBACpE,MAAM,UAAU,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;oBAE3C,yCAAyC;oBACzC,MAAM,aAAa,CAAC,SAAS,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;wBAC3D,OAAO,CAAC,IAAI,CAAC,8CAA8C,SAAS,UAAU,EAAE,GAAG,CAAC,CAAA;oBACtF,CAAC,CAAC,CAAA;gBACJ,CAAC;YACH,CAAC;YAED,0DAA0D;YAC1D,MAAM,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAA;QACrC,CAAC;IACH,CAAC;IAED,6EAA6E;IAE7E,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,WAAW,CAAC,EAAE,MAAM,EAAE,SAAS,GAAG,GAAG,EAAE,CAAC,CAAA;IACzE,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,IAAI,CAAC,6CAA6C,GAAG,GAAG,EAAE,GAAG,CAAC,CAAA;IACxE,CAAC;IAED,6EAA6E;IAE7E,MAAM,EAAE,CAAC,eAAe,CAAC,UAAU,CAAC,CAAA;IAEpC,OAAO,CAAC,GAAG,CAAC,6CAA6C,GAAG,EAAE,CAAC,CAAA;AACjE,CAAC,CAAC,CAAA;AAEF,KAAK,UAAU,aAAa,CAC1B,SAAiB,EACjB,UAA4C,EAC5C,EAA6B;;IAE7B,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAEnC,+DAA+D;IAC/D,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,WAAW,GAAG,MAAA,UAAU,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAC/C,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC9D,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC1B,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3F,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE;;QAC7B,MAAM,CAAC,GAAG,MAAA,GAAG,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QAC3B,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QAChB,CAAC;IACH,CAAC,CAAC,CAAA;IAEF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,uCAAuC;IACvC,MAAM,EAAE;SACL,UAAU,CAAC,OAAO,CAAC;SACnB,GAAG,CAAC,SAAS,CAAC;SACd,UAAU,CAAC,oBAAoB,CAAC;SAChC,GAAG,CAAC;QACH,IAAI,EAAE,yBAAyB;QAC/B,KAAK,EAAE,oCAAoC;QAC3C,IAAI,EAAE,uDAAuD;QAC7D,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEJ,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,UAAU,CAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,SAAS,CAAC,IAAI,CAAC;QACb,KAAK;QACL,YAAY,EAAE;YACZ,KAAK,EAAE,oCAAoC;YAC3C,IAAI,EAAE,uDAAuD;SAC9D;QACD,IAAI,EAAE,EAAE,IAAI,EAAE,yBAAyB,EAAE;KAC1C,CAAC,CACH,CACF,CAAA;IAED,WAAW,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QAChC,IAAI,MAAM,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YACjC,OAAO,CAAC,IAAI,CAAC,oCAAoC,MAAM,CAAC,CAAC,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;QACtF,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC"} \ No newline at end of file diff --git a/functions/src/couples/leaveCoupleCallable.ts b/functions/src/couples/leaveCoupleCallable.ts new file mode 100644 index 00000000..c34ec000 --- /dev/null +++ b/functions/src/couples/leaveCoupleCallable.ts @@ -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 } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index 8d8c464b..513d78d9 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -26,6 +26,8 @@ export { } from './questions/assignDailyQuestion' export { onAnswerWritten } from './questions/onAnswerWritten' export { onCoupleLeave } from './couples/onCoupleLeave' +export { leaveCoupleCallable } from './couples/leaveCoupleCallable' +export { onUserDelete } from './users/onUserDelete' export { onGameSessionUpdate } from './games/onGameSessionUpdate' /** diff --git a/functions/src/users/onUserDelete.ts b/functions/src/users/onUserDelete.ts new file mode 100644 index 00000000..3ef0af95 --- /dev/null +++ b/functions/src/users/onUserDelete.ts @@ -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 { + 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) + } + }) +}