Add live polling and optimistic locking for concurrent inline edits

Two-part fix so multiple people can edit /lignes at once without
clobbering each other, and see each other's changes without a manual
reload -- no Socket.io/websocket infra, just what fits the existing
fetch-based architecture:

1. Polling (POLL_INTERVAL_MS = 8s): pollForChanges() asks JSON:API for
   any ligne_comptable changed since the last check (filtering on the
   `changed` field -- confirmed live that JSON:API only accepts a raw
   Unix timestamp for this, not the ISO string it returns in responses,
   silently matching everything otherwise) and mergeChangedRows()
   patches matching rows in place via Object.assign (not a `rows`
   reassignment, so it doesn't re-trigger the reconciliation-resolution
   watcher for routine polls). A row currently being edited is skipped
   entirely rather than overwritten out from under an in-progress
   keystroke.

2. Optimistic locking: every row now carries its `changed` timestamp,
   sent back on every inline edit (updateType/updateField). A new
   checkConflict() compares it against the node's actual changed time
   before saving and rejects with 409 if they differ -- someone else
   saved this exact line in between. On a 409, refreshSingleRow()
   re-fetches just that node and patches it in place so the view
   self-corrects instead of staying stuck on the stale state that
   caused the rejection.

Verified live end-to-end against an isolated temporary test node (not
real data): an external edit correctly appeared in the browser within
one poll cycle with no reload; a save using a stale `changed` value was
rejected with the conflict error, confirmed via direct DB query that it
left the node's data completely untouched, and the view auto-corrected
to show the other edit. Test node and its paragraph fully cleaned up
afterward.
This commit is contained in:
2026-09-06 12:49:56 +02:00
parent 0c893b1b24
commit 71fde0f3c0
2 changed files with 174 additions and 11 deletions
@@ -74,6 +74,10 @@ class LedgerActionsController extends ControllerBase {
return new JsonResponse(['error' => 'Type de ligne invalide.'], 400);
}
if ($conflict = $this->checkConflict($request, $node)) {
return $conflict;
}
$node->set('field_type_ligne', $type);
// A type that's no longer linkable shouldn't keep a stale
@@ -110,6 +114,7 @@ class LedgerActionsController extends ControllerBase {
'success' => TRUE,
'type' => $type,
'entree_liee_cleared' => !in_array($type, self::LINKABLE_TYPES, TRUE),
'changed' => date(DATE_ATOM, $node->getChangedTime()),
]);
}
@@ -137,6 +142,10 @@ class LedgerActionsController extends ControllerBase {
}
$fieldName = self::INLINE_EDITABLE_FIELDS[$field];
if ($conflict = $this->checkConflict($request, $node)) {
return $conflict;
}
if ($field === 'client') {
if ($value === '') {
$node->set('field_client', NULL);
@@ -186,7 +195,37 @@ class LedgerActionsController extends ControllerBase {
'success' => TRUE,
'field' => $field,
'value' => $newValue,
'changed' => date(DATE_ATOM, $node->getChangedTime()),
]);
}
/**
* Optimistic-locking guard shared by both endpoints above: the
* frontend sends the `changed` timestamp of the row it last saw (see
* buildRows() in home.js), captured at load/last-successful-save
* time. If the node's *actual* changed time has since moved on --
* someone else saved this same line in between -- the two won't
* match, and we reject rather than silently overwrite whatever that
* other save touched. Absent entirely (older cached frontend, or a
* request that genuinely doesn't know it, e.g. a fresh row from
* autocreate) skips the check rather than blocking on a false
* mismatch -- the check is a safety net for the common case, not a
* hard requirement of the API contract.
*/
private function checkConflict(Request $request, NodeInterface $node) {
$data = json_decode($request->getContent(), TRUE);
$clientChanged = is_array($data) ? ($data['changed'] ?? NULL) : NULL;
if ($clientChanged === NULL) {
return NULL;
}
$clientChangedTs = strtotime($clientChanged);
if ($clientChangedTs !== FALSE && $clientChangedTs !== (int) $node->getChangedTime()) {
return new JsonResponse([
'error' => 'Cette ligne a été modifiée par quelqu\'un d\'autre entre-temps. Rechargez la page pour voir les dernières modifications.',
'conflict' => TRUE,
], 409);
}
return NULL;
}
}