Collaborative work / user tracking / How to prevent editing the same entry simultaneously?

#1

I have a multi user setup, where different persons could possibly edit the same entry at the same time. If person 1 saves the entry, person 2 has to be noticed, that the entry changed before she saves - and overwrites the changes from person 1.

Generally, I want to avoid as much user tracking as possible and I don’t want to slow down my application.

Did someone build anything like this before?

This job shouldn’t be too hard, but I don’t want to reinvent the wheel and there might be a lot of edge cases.

This is the sketch I wrote in the last two hours (not very dynamic, yet):

$app->on('cockpit.account.login', function($user) {

    $active_user = [[
        'user' => $user['user'],
        'name' => $user['name'],
        '_id' => $user['_id'],
    ]];

    $this->storage->save('cockpit/collab', $active_user);

});

$app->on('cockpit.account.logout', function($user) {

    $this->storage->remove("cockpit/collab", ['_id' => $user['_id']]);

});

$app->on('app.layout.contentbefore', function() {

    $this->storage->update('cockpit/collab', ['_id' => $this['user']['_id']], ['route' => $this['route']]);

    $users = $this->storage->find('cockpit/collab');

    echo '<div riot-mount><p>active users: ';
    foreach($users as $user) {
        if ($user['_id'] != $this['user']['_id']) {
            if ($user['route'] == $this['route']) {
                echo '<i class="uk-icon-warning" title="'.$user['name'].' is on the same page" data-uk-tooltip></i> ';
            }
            echo '<cp-gravatar alt="'.$user['name'].'" size="25" title="'.$user['name'].'" data-uk-tooltip></cp-gravatar> ';
        }
    }
    echo '</p></div>';

});
#2

It is on my to do list, but I need to figure out an elegant way to do this…it will get implemented in one of the next releases

3 Likes
#3

That would be a very interesting feature, there are some edge cases, but would say that something like below can do the job:

  1. Configuration can define a TTL for entry locks, e.g.:
content_lock:
  page: 3600 #1h
  blog: 7200 #2h
  1. User access entry, if collection type is in the config and there is no lock a new one is created (entry_id + user_id + timestamp)
  2. Another user access entry, if there is a lock and is not expired a message is displayed to the user
  3. Another user access entry, lock is expired, user can access entry
  4. A user with admin group access same entry and lock exists, a prompt is displayed to the user if he wants to break the lock

A possible issue with above is that we can end with n lock entries as cockpit doesn’t have a cron functionality, maybe a cli command can be used to remove expired locks

#4

Not final yet, but the implementation has started: https://github.com/agentejo/cockpit/commit/917ba5afdcb9e8504fa5fbedc0e4b76563a6c62e

#5

Nice.

Some thoughts…

  • Instead of the lock screen, the entry should be displayed with readonly access. Now I’m not able to display the data, just because someone else opened it.
  • It would be interesting, if the locking wouldn’t be per entry, but per field - but this could cause massive requests on larger forms.
  • What if the data changed via api call, e. g. from a different application? The entry can’t be locked in this case.
  • I thought about continuous checks, like the check-backend-session, that reloads the current data at regular intervals. --> Can cause a lot of traffic.
#6

Amazing @artur, let’s start it simple and then increase the complexity

@raffaelj, regarding your points:

  1. At least a message needs to be displayed to the user explaining the reason is not able to access
  2. Lock per field seems overcomplicating to me
  3. For API requests shall be the same if the entry is locked because you are working on it you don’t want it to be updated in background without you notice it, also remember, if that is configurable you can decide on the collection types, in such case usually you create collection types that are expected to be edited in the UI and collection types that are specifically to be created/edited by the API
  4. Yeah, in that case why not reuse the check-backend-session?
#7

Yes, point 3+4 needs to be solved. but it was already too late yesterday :wink:

@raffaelj I always try to implement the most simple possible solution and target the main issue first.

so in this case the most important thing is to prevent editing the same item by multiple users. a lock screen does the job perfectly for now.

Improvements can be made in future release iterations to not block other features/addons I have in mind.

#8

Hello artur, Is there a way to set a TTL on this content lock currently? I have a user who started editing content many days ago and never closed the window on their computer that they don’t currently have access to and I am locked out of my own content.

#9

@BlakeGardner I saw this problem too and I wrote a workaround to disable the lock until the core locks are improved:

#10

I would say that you can have a cron job that cleans the memory keys or use on the admin init like @raffaelj suggested but with a timestamp compare, e.g.:

$app->on('admin.init', function() {
  $keys = $this->memory->keys('locked:*');
  foreach ($keys as $key) {
    $meta = $this->memory->get($key, FALSE);
    if ($meta && $meta['time'] < (time() - 3600 * 6)) {
      $this->memory->del($key);
    }
  }
});

Above will delete locked items older than 6h.

Added a cli command for that in the Helpers addon https://github.com/pauloamgomes/CockpitCMS-Helpers#locks-removal so it can be used in a cron.

1 Like
#11

I think, that won’t work. The current setup has a ttl of 5 minutes. The problem is, that if the browser tab is still open, it sends a request every 2 minutes to lock it again.

I wrote my workaround because I don’t want locked items while testing with multiple browsers and because my Firefox is set up with some privacy settings, so the unlock request on beforeunload is never sent.

#12

@raffaelj yep, didn’t checked in detail, one solution would have a queue with the initial lock time (of the first check - e.g. first cron execution), but that doesnt seem clean. Other option would be if @artur accepts to not re-lock a locked item, resuming on the lockResourceId if the lock already exists do nothing or do not set a new timestamp.

#13

As a quick solution: delete storage/data/memory.sqlite

I’ll think about a solution (e.g. possibility to take over as an admin).

Thanks for your input.

#14

Yep, think the below workflow make sense:

  1. User A edits entry, entry is locked
  2. User B tries to edit entry, locked message is displayed
  3. Admin user tries to edit entry, locked message is displayed with a CTA to break the lock, if admin breaks the lock he can now edit the contents and item is locked on the admin user

Or instead of relying on the user group we can rely on a permission, e.g. collections.unlock