How to add timestamp to every collection insert,update in Cloud Functions for firestore database
Asked Answered
S

6

14

I have a firestore collection called Posts I make an insert on the client side and it works.

I want to add the createdAt and updatedAt fields to every insert in my posts collection firestore using firebase functions.

Slavish answered 5/10, 2018 at 8:0 Comment(2)
What do you mean by "firebase functions."? Do you mean "Cloud Functions for Firebase", firebase.google.com/docs/functions? – Luxurious
Yes @RenaudTarnec – Slavish
L
24

UPDATE 3/5/23 - See my blog post for updates on this:

https://code.build/p/FNqgCcgVsGFrHCRjjf4vg/handling-firestore-timestamps


UPDATE 1/31/21 - While I believe my package is great code and answers the question, there is a cheaper way of doing this: firestore rules:

allow create: if request.time == request.resource.data.createdAt;
allow update: if request.time == request.resource.data.updatedAt;

If the updatedAt or createdAt are not added on the front end with the correct date and time, it will not allow the update / create. This is much cheaper as it does not require a function for data, nor an extra write everytime you update something.

Do not use a regular date field, be sure to add the timestamp on the frontend via:

firebase.firestore.FieldValue.serverTimestamp;

I created a universal cloud function to update whatever documents you want with the createdAt and updatedAt timestamp:

exports.myFunction = functions.firestore
    .document('{colId}/{docId}')
    .onWrite(async (change, context) => {

        // the collections you want to trigger
        const setCols = ['posts', 'reviews','comments'];

        // if not one of the set columns
        if (setCols.indexOf(context.params.colId) === -1) {
            return null;
        }

        // simplify event types
        const createDoc = change.after.exists && !change.before.exists;
        const updateDoc = change.before.exists && change.after.exists;
        const deleteDoc = change.before.exists && !change.after.exists;

        if (deleteDoc) {
            return null;
        }
        // simplify input data
        const after: any = change.after.exists ? change.after.data() : null;
        const before: any = change.before.exists ? change.before.data() : null;

        // prevent update loops from triggers
        const canUpdate = () => {
            // if update trigger
            if (before.updatedAt && after.updatedAt) {
                if (after.updatedAt._seconds !== before.updatedAt._seconds) {
                    return false;
                }
            }
            // if create trigger
            if (!before.createdAt && after.createdAt) {
                return false;
            }
            return true;
        }

        // add createdAt
        if (createDoc) {
            return change.after.ref.set({
                createdAt: admin.firestore.FieldValue.serverTimestamp()
            }, { merge: true })
                .catch((e: any) => {
                    console.log(e);
                    return false;
                });
        }
        // add updatedAt
        if (updateDoc && canUpdate()) {
            return change.after.ref.set({
                updatedAt: admin.firestore.FieldValue.serverTimestamp()
            }, { merge: true })
                .catch((e: any) => {
                    console.log(e);
                    return false;
                });
        }
        return null;
    });


Levulose answered 1/4, 2020 at 4:6 Comment(11)
This is great stuff – you should really write this up in a Medium article or something. The only thing that I think that it's missing is the ability to include subcollections. I'll have a think about how that might happen πŸ‘Œ – Azoth
done, you could easily do it with sub-collections my making two universal sub-collections... see my website link above – Levulose
Nice solution @Jonathan. I had gone ahead and posted a solution (https://mcmap.net/q/793616/-how-to-add-timestamp-to-every-collection-insert-update-in-cloud-functions-for-firestore-database) to the subcollections query, but hadn't seen your update till just now - cool package! – Mesmerize
This is a brilliant solution. πŸ‘ I second @Azoth that you should write a Medium article. A lot of tutorials and SO answers use patterns to insert server timestamp from the client. This is a much simpler and fail-free pattern. πŸ‘πŸ‘ – Teryl
I did write an article on my blog, however, see my updated answer. Thanks! – Levulose
The one benefit the triggers has over the Firestore rules method is that it behaves more like a SQL database. That means you can accept data from any source. Rules will require that you are accessing the Firestore DB using the native SDK since you need serverTimestamp(). Function invocations aren't very expensive but having the n + 1 write might be. Just some food for thought for those considering which method to choose. – Grannias
@Levulose - In order to include createdAt in each write, wouldn't you need to either know definitively that the document doesn't exist yet or already have the current value on the client? Given that both of those would require a database read (which you'd have to do before every write), it seems like it'd be cheaper to use the function method for createdAt and the rules method for updatedAt. Or am I missing something? – Socle
@Socle - The function checks for createDoc to see if the doc exists. I would still just use rules for both, as it is much much cleaner. – Levulose
I get the following error: 'Type annotation can only be used in typescript files' – Kanishakanji
It is confusing allow create: if request.time == request.resource.data.createdAt; allow update: if request.time == request.resource.data.updatedAt; is not adding createdAt server side. It is stopping create. Why do you include that reference? – Unable
Those are Firebase rules. The creation is created with on the server, you tell Firebase to do this with serverTimestamp() – Levulose
L
17

In order to add a createdAt timestamp to a Post record via a Cloud Function, do as follows:

exports.postsCreatedDate = functions.firestore
  .document('Posts/{postId}')
  .onCreate((snap, context) => {
    return snap.ref.set(
      {
        createdAt: admin.firestore.FieldValue.serverTimestamp()
      },
      { merge: true }
    );
  });

In order to add a modifiedAt timestamp to an existing Post you could use the following code. HOWEVER, this Cloud Function will be triggered each time a field of the Post document changes, including changes to the createdAt and to the updatedAt fields, ending with an infinite loop....

exports.postsUpdatedDate = functions.firestore
  .document('Posts/{postId}')
  .onUpdate((change, context) => {
    return change.after.ref.set(
      {
        updatedAt: admin.firestore.FieldValue.serverTimestamp()
      },
      { merge: true }
    );
  });

So you need to compare the two states of the document (i.e. change.before.data() and change.after.data() to detect if the change is concerning a field that is not createdAt or updatedAt.

For example, imagine your Post document only contains one field name (not taking into account the two timestamp fields), you could do as follows:

exports.postsUpdatedDate = functions.firestore
  .document('Posts/{postId}')
  .onUpdate((change, context) => {
    const newValue = change.after.data();
    const previousValue = change.before.data();

    if (newValue.name !== previousValue.name) {
      return change.after.ref.set(
        {
          updatedAt: admin.firestore.FieldValue.serverTimestamp()
        },
        { merge: true }
      );
    } else {
      return false;
    }
  });

In other words, I'm afraid you have to compare the two document states field by field....

Luxurious answered 5/10, 2018 at 11:1 Comment(4)
Thank you very much for your answer. I havnt tried it yet. I am looking to use the insert option first only.Let me cme back to this one becuase i mark it as an answer. Thank you sir – Slavish
@Slavish Hello, did you have the opportunity to check if you could accept the answer? – Luxurious
@renuad no i didnt. – Slavish
for the true doc create timestamp, use createdAt: snap.createTime instead. FieldValue.serverTimestamp() has an issue in that it sets the timestamp based on the invocation of the onCreate function, which can be a few hundred or thousand milliseconds later. – Hosiery
F
1

This is what I have used to prevent the firebase firestore infinite loop.
I prefer to put the logic in a onWrite compared to onUpdate trigger
I use the npm package fast-deep-equal to compare changes between incoming and previous data.

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

const equal = require('fast-deep-equal/es6');

export const notificationUpdated = functions.firestore
  .document('notifications/{notificationId}')
  .onWrite((change, context) => {
    // Get an object with the current document value.
    // If the document does not exist, it has been deleted.
    const document = change.after.exists ? change.after.data() : null;

    // Get an object with the previous document value (for update or delete)
    const oldDocument = change.before.data();

    if (document && !change.before.exists) {
      // This is a new document

      return change.after.ref.set(
        {
          createdAt: admin.firestore.FieldValue.serverTimestamp(),
          updatedAt: admin.firestore.FieldValue.serverTimestamp()
        },
        { merge: true }
      );
    } else if (document && change.before.exists) {
      // This is an update

      // Let's check if it's only the time that has changed.
      // I'll do this by making updatedAt a constant, then use `fast-deep-equal` to compare the rest
      const onlyTimeChanged = equal({ ...oldDocument, updatedAt: 0 }, { ...document, updatedAt: 0 });
      console.log(`Only time changed? ${onlyTimeChanged}`);
      if (onlyTimeChanged) {
        // The document has just been updated.
        // Prevents an infinite loop
        console.log('Only time has changed. Aborting...');
        return false;
      }
      return change.after.ref.set(
        {
          updatedAt: admin.firestore.FieldValue.serverTimestamp()
        },
        { merge: true }
      );
    } else if (!document && change.before.exists) {
      // This is a doc delete

      // Log or handle it accordingly
      return false;
    } else {
      return false;
    }
  });


Hope this helps

Franco answered 13/2, 2020 at 11:56 Comment(0)
P
0
const after = change.after.data();
const before = change.before.data();
const check = Object.keys(after).filter(key => (key !== 'createdAt') && (key !== 'updatedAt')).map(key => after[key] != before[key]);
if (check.includes(true)) {
    return change.after.ref.set(
        {
            updatedAt: admin.firestore.FieldValue.serverTimestamp()
        },
        { merge: true }
    );
} else {
    return false;
}
Preternatural answered 13/2, 2020 at 12:0 Comment(1)
Please try to explain clearly when you answered a question. – Faunia
M
0

This solution supports first-level subcollections and is based off @Jonathan's answer above:

    **
     * writes fields common to root-level collection records that are generated by the
     * admin SDK (backend):
     * - createdAt (timestamp)
     * - updatedAt (timestamp)
     */
    exports.createCommonFields = functions.firestore
    .document('{colId}/{docId}')
    .onWrite(async (change, context) => {
        // the collections you want to trigger
        const setCols = ['posts', 'reviews', 'comments', ];
    
        // run the field creator if the document being touched belongs to a registered collection
        if (setCols.includes(context.params.colId)) {
            console.log(`collection ${context.params.colId} is not registered for this trigger`);
            return null;
        } else {
            console.log(`running createCommonFields() for collection: ${context.params.colId}`);
        }
    
        // cause the creation of timestamp fields only
        _createCommonFields(change);
    });
    
    /**
     * createCommonFields' equivalent for sub-collection records
     */
    exports.createCommonFieldsSubColl = functions.firestore
    .document('{colId}/{colDocId}/{subColId}/{subColDocId}')
    .onWrite(async (change, context) => {
        console.log(`collection: ${context.params.colId}, subcollection: ${context.params.subColId}`);
    
        // the subcollections of the collections you want to trigger
        // triggers for documents like 'posts/postId/versions/versionId, etc
        const setCols = {
            'posts': ['versions', 'tags', 'links', ], 
            'reviews': ['authors', 'versions'],
            'comments': ['upvotes', 'flags'],
        };
    
        // parse the collection and subcollection names of this document
        const colId = context.params.colId;
        const subColId = context.params.subColId;
        // check that the document being triggered belongs to a registered subcollection
        // e.g posts/versions; skip the field creation if it's not included
        if (setCols[colId] && setCols[colId].includes(subColId)) {
            console.log(`running createCommonFieldsSubColl() for this subcollection`);
        } else {
            console.log(`collection ${context.params.colId}/${context.params.subColId} is not registered for this trigger`);
            return null;
        }
    
        // cause the creation of timestamp fields
        _createCommonFields(change);
    });
    
    /**
     * performs actual creation of fields that are common to the
     * registered collection being written
     * @param {QueryDocumentSnapshot} change a snapshot for the collection being written
     */
    async function _createCommonFields(change) {
        // simplify event types
        const createDoc = change.after.exists && !change.before.exists;
        const updateDoc = change.before.exists && change.after.exists;
        const deleteDoc = change.before.exists && !change.after.exists;
    
        if (deleteDoc) {
            return null;
        }
    
        // simplify input data
        const after = change.after.exists ? change.after.data() : null;
        const before = change.before.exists ? change.before.data() : null;
    
        // prevent update loops from triggers
        const canUpdate = () => {
            // if update trigger
            if (before.updatedAt && after.updatedAt) {
                if (after.updatedAt._seconds !== before.updatedAt._seconds) {
                    return false;
                }
            }
            // if create trigger
            if (!before.createdAt && after.createdAt) {
                return false;
            }
            return true;
        }
  
        const currentTime = admin.firestore.FieldValue.serverTimestamp();
        // add createdAt
        if (createDoc) {
            return change.after.ref.set({
                createdAt: currentTime,
                updatedAt: currentTime,
            }, { merge: true })
            .catch((e) => {
                console.log(e);
                return false;
            });
        }
        // add updatedAt
        if (updateDoc && canUpdate()) {
            return change.after.ref.set({
                updatedAt: currentTime,
            }, { merge: true })
            .catch((e) => {
                console.log(e);
                return false;
            });
        }
        return null;
    }
Mesmerize answered 25/11, 2020 at 4:59 Comment(0)
V
-1

You do not need Cloud Functions to do that. It is much simpler (and cheaper) to set server timestamp in client code as follows:

var timestamp = firebase.firestore.FieldValue.serverTimestamp()   
post.createdAt = timestamp
post.updatedAt = timestamp
Valois answered 11/2, 2019 at 7:6 Comment(7)
But then someone could modify this, cheating the system and faking a date created. – Shealy
@DustinSilk Interesting to know if there really is such an issue with Angular apps (I develop actually mainly mobile apps). How do you then protect your other data from being tampered and why can't you do the same for this timestamp? If someone really has tampered your data, having possibly a valid timestamp does not improve the situation much. – Valois
If a timestamp means your content is given priority in a community, for example, there would be motive to be able to change it. That means the user on the frontend could easily intercept the http call and change the timestamp to their own benefit. Securing other data depends on what it is. If its user created data, often it's okay for them to change it anyways, otherwise there needs to be checks on the server to validate it. – Shealy
Why would you use http instead of https? If security is important, http calls are not an option. – Valois
You're missing the point. It doesnt matter if it is http or https. With either one, the user can edit the javascript and can easily change the timestamp. – Shealy
Ok, just wanted to confirm that as you said "could easily intercept the http call". – Valois
If you use the firestore rules you can actually "secure" this. The "UPDATE 1/31/21" answer above that includes this snippet: allow create: if request.time == request.resource.data.createdAt; – Deiform

© 2022 - 2024 β€” McMap. All rights reserved.