const {initializeApp}=require('firebase-admin/app'); const {getFirestore,FieldValue}=require('firebase-admin/firestore'); const {getAuth}=require('firebase-admin/auth'); const {onCall,HttpsError}=require('firebase-functions/v2/https'); initializeApp(); const db=getFirestore(); const options={region:'europe-west1',maxInstances:3}; function uidOf(request){if(!request.auth)throw new HttpsError('unauthenticated','Connexion requise.');return request.auth.uid;} exports.deleteEntity=onCall(options,async request=>{ const uid=uidOf(request),{kind,id}=request.data??{}; if(!['measurement','session'].includes(kind)||typeof id!=='string'||!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new HttpsError('invalid-argument','Entité invalide.'); const user=db.doc(`users/${uid}`),ref=user.collection(kind==='measurement'?'measurements':'sessions').doc(id); // Tombstone and deletion are atomic. It remains even if cleanup below retries. await db.runTransaction(async tx=>{const profile=await tx.get(user);if(profile.data()?.accountState!=='active')throw new HttpsError('failed-precondition','Compte indisponible.');const tombstone=user.collection('tombstones').doc(`${kind}_${id}`);const existing=await tx.get(tombstone);if(!existing.exists)tx.create(tombstone,{schemaVersion:1,entityId:id,entityKind:kind,createdAt:FieldValue.serverTimestamp(),updatedAt:FieldValue.serverTimestamp()});tx.delete(ref);if(kind==='measurement')tx.delete(user.collection('measurementAnnotations').doc(id));}); if(kind==='measurement'){ // Bounded batches; repeated calls finish cleanup after a transient failure. for(const field of ['beforeMeasurementId','afterMeasurementId']){ let page; do {page=await user.collection('sessions').where(field,'==',id).limit(200).get();if(page.empty)break;const batch=db.batch();for(const d of page.docs)batch.update(d.ref,{[field]:null,revision:FieldValue.increment(1),updatedAt:FieldValue.serverTimestamp()});await batch.commit();}while(!page.empty); } } // Derived data is disposable. Recompute before exposing it again. await db.recursiveDelete(user.collection('dailyStats')); await db.recursiveDelete(user.collection('baselines')); return {deleted:true}; }); exports.deleteAccount=onCall(options,async request=>{ const uid=uidOf(request); if(Date.now()/1000-request.auth.token.auth_time>300)throw new HttpsError('failed-precondition','Reconnecte-toi avant de supprimer ton compte.'); const user=db.doc(`users/${uid}`); await user.set({accountState:'deleting',updatedAt:FieldValue.serverTimestamp()},{merge:true}); // Keep blocking profile until all child collections have been removed. for(const collection of await user.listCollections())await db.recursiveDelete(collection); await getAuth().revokeRefreshTokens(uid); try{await getAuth().deleteUser(uid);}catch(e){if(e.code!=='auth/user-not-found')throw e;} // Retain a minimal deleting marker to deny old ID-token replay until expiry. // No profile or health information remains in the marker. await user.set({schemaVersion:1,accountState:'deleting',updatedAt:FieldValue.serverTimestamp()}); return {deleted:true}; });