Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Delete same value from multiple locations Firebase Functions

I have a firebase function that deletes old messages after 24 hours as in my old question here. I now have just the messageIds stored in an array under the user such that the path is: /User/objectId/myMessages and then an array of all the messageIds under myMessages. All of the messages get deleted after 24 hours, but the iDs under the user's profile stay there. Is there a way to continue the function so that it also deletes the messageIds from the array under the user's account?

I'm new to Firebase functions and javascript so I'm not sure how to do this. All help is appreciated!

like image 587
Jaqueline Avatar asked Jan 01 '23 09:01

Jaqueline


2 Answers

Building upon @frank-van-puffelen's accepted answer on the old question, this will now delete the message IDs from their sender's user data as part of the same atomic delete operation without firing off a Cloud Function for every message deleted.

Method 1: Restructure for concurrency

Before being able to use this method, you must restructure how you store entries in /User/someUserId/myMessages to follow best practices for concurrent arrays to the following:

{
  "/User/someUserId/myMessages": {
    "-Lfq460_5tm6x7dchhOn": true,
    "-Lfq483gGzmpB_Jt6Wg5": true,
    ...
  }
}

This allows you to modify the previous function to:

// Cut off time. Child nodes older than this will be deleted.
const CUT_OFF_TIME = 24 * 60 * 60 * 1000; // 2 Hours in milliseconds.

exports.deleteOldMessages = functions.database.ref('/Message/{chatRoomId}').onWrite(async (change) => {
    const rootRef = admin.database().ref(); // needed top level reference for multi-path update
    const now = Date.now();
    const cutoff = (now - CUT_OFF_TIME) / 1000; // convert to seconds
    const oldItemsQuery = ref.orderByChild('seconds').endAt(cutoff);
    const snapshot = await oldItemsQuery.once('value');
    // create a map with all children that need to be removed
    const updates = {};
    snapshot.forEach(messageSnapshot => {
        let senderId = messageSnapshot.child('senderId').val();
        updates['Message/' + messageSnapshot.key] = null; // to delete message
        updates['User/' + senderId + '/myMessages/' + messageSnapshot.key] = null; // to delete entry in user data
    });
    // execute all updates in one go and return the result to end the function
    return rootRef.update(updates);
});

Method 2: Use an array

Warning: This method falls prey to concurrency issues. If a user was to post a new message during the delete operation, it's ID could be removed while evaluating the deletion. Use method 1 where possible to avoid this.

This method assumes your /User/someUserId/myMessages object looks like this (a plain array):

{
  "/User/someUserId/myMessages": {
    "0": "-Lfq460_5tm6x7dchhOn",
    "1": "-Lfq483gGzmpB_Jt6Wg5",
    ...
  }
}

The leanest, most cost-effective, anti-collision function I can come up for this data structure is the following:

// Cut off time. Child nodes older than this will be deleted.
const CUT_OFF_TIME = 24 * 60 * 60 * 1000; // 2 Hours in milliseconds.

exports.deleteOldMessages = functions.database.ref('/Message/{chatRoomId}').onWrite(async (change) => {
    const rootRef = admin.database().ref(); // needed top level reference for multi-path update
    const now = Date.now();
    const cutoff = (now - CUT_OFF_TIME) / 1000; // convert to seconds
    const oldItemsQuery = ref.orderByChild('seconds').endAt(cutoff);
    const snapshot = await oldItemsQuery.once('value');
    // create a map with all children that need to be removed
    const updates = {};
    const messagesByUser = {};
    snapshot.forEach(messageSnapshot => {
        updates['Message/' + messageSnapshot.key] = null; // to delete message

        // cache message IDs by user for next step
        let senderId = messageSnapshot.child('senderId').val();
        if (!messagesByUser[senderId]) { messagesByUser[senderId] = []; }
        messagesByUser[senderId].push(messageSnapshot.key);
    });

    // Get each user's list of message IDs and remove those that were deleted.
    let pendingOperations = [];
    for (let [senderId, messageIdsToRemove] of Object.entries(messagesByUser)) {
        pendingOperations.push(admin.database.ref('User/' + senderId + '/myMessages').once('value')
            .then((messageArraySnapshot) => {
                let messageIds = messageArraySnapshot.val();
                messageIds.filter((id) => !messageIdsToRemove.includes(id));
                updates['User/' + senderId + '/myMessages'] = messageIds; // to update array with non-deleted values
            }));
    }
    // wait for each user's new /myMessages value to be added to the pending updates
    await Promise.all(pendingOperations);

    // execute all updates in one go and return the result to end the function
    return ref.update(updates);
});
like image 79
samthecodingman Avatar answered Jan 02 '23 23:01

samthecodingman


Update: DO NOT USE THIS ANSWER (I will leave it as it may still be handy for detecting a delete operation for some other need, but do not use for the purpose of cleaning up an array in another document)

Thanks to @samthecodingman for providing an atomic and concurrency safe answer.

If using Firebase Realtime Database you can add an onChange event listener:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(); 

exports.onDeletedMessage = functions.database.ref('Message/{messageId}').onChange(async event => {

    // Exit if this item exists... if so it was not deleted!
    if (event.data.exists()) {
        return;
    }

    const userId = event.data.userId; //hopefully you have this in the message document
    const messageId = event.data.messageId;

    //once('value') useful for data that only needs to be loaded once and isn't expected to change frequently or require active listening
    const myMessages = await functions.database.ref('/users/' + userId).once('value').snapshot.val().myMessages;

    if(!myMessages || !myMessages.length) {
        //nothing to do, myMessages array is undefined or empty
        return;
    }

    var index = myMessages.indexOf(messageId);

    if (index === -1) {
        //nothing to delete, messageId is not in myMessages
        return;
    }

    //removeAt returns the element removed which we do not need
    myMessages.removeAt(index);
    const vals = {
        'myMessages': myMessages;
    }

    await admin.database.ref('/users/' + userId).update(vals);

});

If using Cloud Firestore can add an event listener on the document being deleted to handle cleanup in your user document:

exports.onDeletedMessage = functions.firestore.document('Message/{messageId}').onDelete(async event => {
  const data = event.data();

  if (!data) {
    return;
  }

  const userId = data.userId; //hopefully you have this in the message document
  const messageId = data.messageId;

  //now you can do clean up for the /user/{userId} document like removing the messageId from myMessages property
  const userSnapShot = await admin.firestore().collection('users').doc(userId).get().data();

  if(!userSnapShot.myMessages || !userSnapShot.myMessages.length) {
      //nothing to do, myMessages array is undefined or empty
      return;
  }

  var index = userSnapShot.myMessages.indexOf(messageId);

  if (index === -1) {
      //nothing to delete, messageId is not in myMessages
      return;
  }

  //removeAt returns the element removed which we do not need
  userSnapShot.myMessages.removeAt(index);
  const vals = {
        'myMessages': userSnapShot.myMessages;
  }

  //To update some fields of a document without overwriting the entire document, use the update() method
  await admin.firestore().collection('users').doc(userId).update(vals);

});
like image 36
Brian Ogden Avatar answered Jan 02 '23 22:01

Brian Ogden