4

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!

Brian Ogden
  • 18,439
  • 10
  • 97
  • 176
Jaqueline
  • 465
  • 2
  • 8
  • 25

2 Answers2

5

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);
});
samthecodingman
  • 23,122
  • 4
  • 30
  • 54
  • Here is a link for the [Firebase RTDB multi-path updates documentation](https://firebase.google.com/docs/database/web/read-and-write#update_specific_fields). – samthecodingman Dec 28 '19 at 03:54
  • Have updated the answer to handle traditional arrays. – samthecodingman Dec 28 '19 at 04:44
  • Thank you so much for your help! There's just one thing; I'm getting a "Parsing error: Unexpected token oldItemsQuery eslint [19,24] and that's for the const snapshot = await oldItemsQuery.once('value') . Do you know what that means or how to fix it? – Jaqueline Dec 29 '19 at 07:10
  • Likely due to an incorrect eslint config that doesn't correctly identify `await` syntax. Try using `{ "parserOptions": { "ecmaVersion": 8 } }` – samthecodingman Dec 29 '19 at 07:44
  • I'm sorry but where do I type that? – Jaqueline Dec 29 '19 at 07:49
  • You should have a file called `.eslintrc` in your project directory – samthecodingman Dec 29 '19 at 07:50
  • It's odd when I added the Firebase Functions I didn't get a file in my Xcode Project file for Functions. In a different file (I don't know where it came to be) I have: index.js, package-lock.json, package.json, and a folder of node_modules which has a bunch of things and eslint but no eslintrtc. This is very weird and I'm not sure where it should be? – Jaqueline Dec 29 '19 at 07:57
  • Your `eslint` config might be stored inside the `package.json`. See the [docs](https://eslint.org/docs/user-guide/configuring) for details. – samthecodingman Dec 29 '19 at 08:29
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/205016/discussion-between-jaqueline-and-samthecodingman). – Jaqueline Dec 29 '19 at 08:37
4

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

});
Brian Ogden
  • 18,439
  • 10
  • 97
  • 176
  • Thank you for that was very helpful! Would you be able to give a basic function for how to remove it from the user? I'm very new to Javascript but I would imagine it goes similar to how the other function goes? I just don't know how it all connects so if you could add a basic function for deleting that property it would be very helpful! – Jaqueline Dec 27 '19 at 21:55
  • @Jaqueline sure I added some code that should help you delete the messageId from the myMessages area in the users/{userId} document – Brian Ogden Dec 27 '19 at 22:16
  • I’m using the real-time database instead of Firestore. Do I just replace “firestore” for “database” – Jaqueline Dec 28 '19 at 01:05
  • No I don't think my answer will work for you with real-time database. There is not delete event listener for real-time database I believe – Brian Ogden Dec 28 '19 at 02:50
  • @Jaqueline I added a Firebase Realtime Database example for you – Brian Ogden Dec 28 '19 at 03:26
  • These functions will lead to race conditions if working on messages from the same user. Let's say that messageId1 and messageId2 were both sent by the same user. Unless those delete operations happen sequentially, one of the deleted IDs may be readded by the other delete operation's update. – samthecodingman Dec 28 '19 at 04:03
  • @samthecodingman what is the problem with that? The function is exited if the messageId is not in the array? – Brian Ogden Dec 28 '19 at 21:03
  • You seem to misunderstand what I'm getting at, have a read of [this blog post](https://firebase.googleblog.com/2014/04/best-practices-arrays-in-firebase.html#arrays-are-evil). – samthecodingman Dec 28 '19 at 21:23
  • @samthecodingman I understand now thanks, I almost deleted my answer but I have left it for posterity with a clear warning at the top. – Brian Ogden Dec 28 '19 at 23:19
  • @Jaqueline go with samthecodingman's answer and just restructure your myMessages for concurrency, keep life simple – Brian Ogden Dec 28 '19 at 23:20
  • 1
    @samthecodingman thank you both of you for really going in depth with this! It's very much appreciated! – Jaqueline Dec 29 '19 at 07:11