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:
parent
cbaa68ef2e
commit
6828be72fc
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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; } });
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue