0

I'm building an app in android that manages users and shifts. Each user can be assigned to multiple shifts and every shift can have many users. Each shift is associated with a single date.

This is how my database is structured in firebase :

 {
  "shift-assign" : {
    "key1" : {
      "111" : true,
      "222" : true,
      "888" : true
    },
    "key2" : {
      "111" : true,
      "222" : true
    }
  },
  "shifts" : {
    "key1" : {
      "date" : "20191202",
      "endTime" : "02:00",
      "numOfEmps" : 4,
      "startTime" : "21:00",
      "wage" : 30
    },
    "key2" : {
      "date" : "20191202",
      "endTime" : "12:00",
      "numOfEmps" : 3,
      "startTime" : "08:00",
      "wage" : 10
    },
    "key3" : {
      "date" : "20191203",
      "endTime" : "06:00",
      "numOfEmps" : 3,
      "startTime" : "00:00",
      "wage" : 30
    }
  },
  "user-assign" : {
    "111" : {
      "key1" : true,
      "key2" : true
    },
    "222" : {
      "key1" : true,
      "key2" : true
    },
    "888" : {
      "key1" : true
    }
  },
  "users" : {
    "111" : {
      "active" : true,
      "email" : "111",
      "firstName" : "aaa",
      "id" : "111",
      "lastName" : "aaa",
      "password" : "111",
      "phone" : "1111111"
    },
    "222" : {
      "active" : true,
      "email" : "222",
      "firstName" : "bbb",
      "id" : "222",
      "lastName" : "bbb",
      "password" : "222",
      "phone" : "4444444"
    },
    "888" : {
      "active" : false,
      "email" : "888",
      "firstName" : "ccc",
      "id" : "888",
      "lastName" : "ccc",
      "password" : "888",
      "phone" : "5555555"
    },
    "99999" : {
      "active" : true,
      "firstName" : "Admin",
      "id" : "99999",
      "password" : "123"
    }
  }
}

When a user tries to assign themselves to a shift I want to check for a few constraints before actually assigning them to the shift.

I tried using a transaction but the code got messed up because I needed to reference different nodes inside a transaction.

My code now looks like this:

  private void AssignUserToShift() {
        // Constraint No. 0
        // user cannot be assigned to shifts whose date < today
        if(dates[0].compareTo(LocalDateTime.now().toLocalDate().format(dtf)) < 0){
            Toast.makeText(getApplicationContext(), "Cannot assign to prior dates", Toast.LENGTH_SHORT).show();
            return;
        }
        Map<String, Object> pathsToUpdate = new HashMap<>();
        pathsToUpdate.put("shift-assign/" + shiftKey + "/" + userId, true);
        pathsToUpdate.put("user-assign/" + userId + "/" + shiftKey, true);
        mDatabase.updateChildren(pathsToUpdate, new DatabaseReference.CompletionListener() {
            @Override
            public void onComplete(@Nullable DatabaseError databaseError, @NonNull DatabaseReference databaseReference) {
                if(databaseError == null) {
                    // add to adapter's list in GUI
                    AddUserToList();
                }else{
                    Toast.makeText(getApplicationContext(), "Could not update database entries", Toast.LENGTH_SHORT).show();
                }
            }
        });
    }

And the opposite method:

private void RemoveUserFromShift() {
    mUser user = userList.stream()
            .filter(u -> u.getId().equals(userId))
            .findAny()
            .orElse(null);
    if(user == null){
        Toast.makeText(UsersAssignedActivity.this, "You are not assigned to this shift.", Toast.LENGTH_SHORT).show();
        return;
    }

    AlertDialog.Builder builder = new AlertDialog.Builder(UsersAssignedActivity.this, R.style.AlertDialogCustom);
    builder.setMessage("Do you wish to remove yourself from this shift?");
    builder.setCancelable(false);
    builder.setPositiveButton("Yes",
            new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int i) {
                    Map<String, Object> pathsToDelete = new HashMap<>();
                    pathsToDelete.put("shift-assign/" + shiftKey + "/" + userId, null);
                    pathsToDelete.put("user-assign/" + userId + "/" + shiftKey, null);
                    mDatabase.updateChildren(pathsToDelete, new DatabaseReference.CompletionListener() {
                        @Override
                        public void onComplete(@Nullable DatabaseError databaseError, @NonNull DatabaseReference databaseReference) {
                            if(databaseError == null){
                                // remove from adapter's list in GUI
                                RemoveUserFromList();
                            }else{
                                Toast.makeText(UsersAssignedActivity.this, "Could not delete database entries", Toast.LENGTH_SHORT).show();
                            }
                        }
                    });
                }
            });
    builder.setNegativeButton("No",
            new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int i) {
                    dialog.cancel();
                }
            });
    AlertDialog alertDialog = builder.create();
    alertDialog.show();
}

So I thought I'd use rules instead and check the constraints server side.

I want to write rules that satisfy the following conditions -

  1. User shall not be able to assign to a shift that is full. (its numOfEmps equals to the number of children nodes under shift-assign/shiftKey)

  2. User shall not be able to assign to more than half of the shifts in a certain date (if there are 4 shifts associated with the same date and the user is already assigned to one of those shifts, they will only be allowed to assign themselves to one more shift in that date.)

    1. User shall not be able to work more than 4 days in a row. (if the user tries to assign themselves to a shift in a certain date and the user is already assigned to a shift(s) in the previous 4 days, they will not be able to assign themselves to this shift)

    2. Lastly if the user is not already assigned to this shift. I guess this can be checked client-side using a transaction.

How can I write these rules?

Jadenkun
  • 317
  • 2
  • 16

1 Answers1

0

You have a really broad use-case in your question. I'll show you how to start with the first requirement:

User shall not be able to assign to a shift that is full. (its numOfEmps equals to the number of children nodes under shift-assign/shiftKey)

Security rules can't perform any aggregations on the data. Since this requirement requires that you compare two values, both values must be stored in a database at a path that is known within the rule.

You already have the numOfEmps, but you'll also need to store the number of folks that have already signed up for the shift. For example, you could add the number of sign-ups to the shift, and then update it with every add/delete:

"shifts" : {
  "key1" : {
    "date" : "20191202",
    "endTime" : "02:00",
    "numOfEmps" : 4,
    "numSignups" : 3,
    "startTime" : "21:00",
    "wage" : 30
  },

Now you could check in security rules whether there are open slots with:

{
  "rules": {
    "shift-assigns": {
      "$shiftid": {
        "$uid": {
          ".validate": "root.child('/shifts/$shiftid/numSignups').val() < root.child('/shifts/$shiftid/numOfEmps').val()"
        }
      }
    }
  }
}

Of course you'll also want to ensure that the user:

  • Can only add/remove themselves to/from a shift, with something like $uid === auth.uid.
  • Can only add themselves to a shift once, but your data model already ensure that.
  • A write operation also updates the new numSignups. This is possible in security rules, although a bit involved. For a longer explanation, see my answer here: Is the way the Firebase database quickstart handles counts secure?

After you've done this, you can start on your other requirements, one by one, until you have all of them covered.

All in all, your security rules will become quite complex for all these use-cases. And while your requirements all seem possible, you'll need to spend a fair amount of time learning how to deal with the limitations of security rules, and them how to implement your use-cases in them.

An alternative is to implement the same logic in a Cloud Function that you then call from within your application code. Cloud Functions are a bit more natural for most developer to write, and can also be a valid option.

Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807