Firestore security rules

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    match /_deeplink_/{deeplink} {
      allow read: if true;
    }

    match /users/{uid} {
      allow read: if true;
    }

    match /clubs/{clubId} {
      // Return true if the user is removing himself from the room.
      function isLeaving() {
        return onlyRemoving('users', request.auth.uid);
      }

      // Return true if the user is adding himself to the room.
      function isJoining() {
        return
          onlyAddingOneElement('users')
          && 
          request.resource.data.users.toSet().difference(resource.data.users.toSet()) == [request.auth.uid].toSet();
      }
      allow read: if true;
      allow create: if (request.resource.data.uid == request.auth.uid);
      allow update: if (resource.data.uid == request.auth.uid) || isJoining() || isLeaving();
      allow delete: if resource.data.uid == request.auth.uid;
    }

    match /meetups/{meetupId} {
      function isLeaving() {
        return onlyRemoving('users', request.auth.uid);
      }
      function isJoining() {
        return
          onlyAddingOneElement('users')
          && 
          request.resource.data.users.toSet().difference(resource.data.users.toSet()) == [request.auth.uid].toSet();
      }
      allow read: if true;
      allow create: if (request.resource.data.uid == request.auth.uid);
      allow update: if (resource.data.uid == request.auth.uid) || isJoining() || isLeaving();      
    }
  }
}

// Return true if the array field in the document is removing only the the element. It must maintain other elements.
//
// arrayField is an array
// [element] is an element to be removed from the arrayField
//
// Returns
// - true if it try to remove an element that is not existing int the array and no other fields are changed.
// - false if the document does not exsit (especially when you put it on "update if: ...").
// 
// Use case;
// Other users can add or remove only their uid from the followers array of the otehr user document
// match /users/{documentId} {
//   allow update: if request.auth.uid == documentId || onlyAdding('followers', request.auth.uid) || onlyRemoving('followers', request.auth.uid)
// }
function onlyRemoving(arrayField, element) {

  let oldSet = (arrayField in resource.data ? resource.data[arrayField] : []).toSet();
  let newSet = request.resource.data[arrayField].toSet();

  return
      // If the field does not exist and no other except the field changes, return true.
      // Why? - preventing permission if it tries to remove somthing that does not exist.
      // Does - it jsut return true without producing permission error.
      // Result - when something is deleted from non exisiting array, just pass.
      ( !(arrayField in resource.data) && noFieldChangedExcept(arrayField) )
      ||
      // If the field exists but the element does not exists.
      // Why? - when something is delete when it is not existing, just pass without permission error.
      ( !(element in oldSet) && noFieldChanged() )
      ||
      (
        // If the "arrayField" is the only field that is being chagned,
        onlyUpdating([arrayField])
        &&
        // And if the "element" is the only element that is being removed.
        oldSet.difference(newSet) == [element].toSet()
        &&
        // And if the old set is same as new set meaning, when something is deleted, the old set without the deleted element must have same value with new set.
        oldSet.intersection(newSet) == newSet
      )
  ;
}


// Returns true if it adds only one element to the array field.
//
// * It allows to update other fields in the document.
//
// This must add an elemnt only. Not replacing any other element. It does unique element check.
function onlyAddingOneElement(arrayField) {
  return
    resource.data[arrayField].toSet().intersection(request.resource.data[arrayField].toSet()) == resource.data[arrayField].toSet()
    &&
    request.resource.data[arrayField].toSet().difference(resource.data[arrayField].toSet()).size() == 1
  ;
}

// Returns true if there is no fields that are updated except the specified field.
function noFieldChangedExcept(field) {
  return request.resource.data.diff(resource.data).affectedKeys().hasOnly([field]);
}

// Returns true if there is no fields that are updated.
function noFieldChanged() {
  return request.resource.data.diff(resource.data).affectedKeys().hasOnly([]);
}

// Returns true if only the specified fields are updated in the document.
//
// For instance, the input fields are ['A', 'B'] and if the document is updated with ['A', 'C'], then it return true.
// For instance, the input fields are ['A', 'B'] and if the document is updated with ['C', 'D'], then it return false.
function onlyUpdating(fields) {
  return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
}