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.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<DocumentSnapshot> { cont ->
userRef(userId).get()
.addOnSuccessListener { cont.resume(it) }
.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) }
}
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")

View File

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

View File

@ -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 ->

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 });
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; } });
/**

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'
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'
/**

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)
}
})
}