From 3fcd1cbf0e1e6d807f794b4a3806dcc96b6b54a0 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 11 Sep 2026 21:56:19 -0400 Subject: [PATCH] Guard bulk-action buttons against double-submit (#36) The 4 bulk-action confirm buttons (close/assign/priority/status) called their performBulk*() handler directly on click with no in-flight guard. Double-clicking a confirm button fired two concurrent POST /api/ bulk_operation.php requests for the same ticket IDs, duplicating the close-reason comment, audit-log entry, and Matrix notification on every affected ticket. Each performBulk*() function now takes the clicked button, no-ops if it's already disabled, disables it before firing the request, and re-enables it in .finally() regardless of outcome. Verified by extracting performBulkAssign() from the real source and driving it with a mock lt.api.post() that never resolves until told to: a simulated rapid double-click fired exactly one request (the second call was a no-op while the button was disabled), and a subsequent click after the request resolved correctly fired a new request. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV --- assets/js/dashboard.js | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index fca33fd..f0369dc 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -140,25 +140,25 @@ document.addEventListener('DOMContentLoaded', function() { break; // Bulk operation perform actions case 'perform-bulk-assign': - performBulkAssign(); + performBulkAssign(target); break; case 'close-bulk-assign-modal': closeBulkAssignModal(); break; case 'perform-bulk-priority': - performBulkPriority(); + performBulkPriority(target); break; case 'close-bulk-priority-modal': closeBulkPriorityModal(); break; case 'perform-bulk-status': - performBulkStatusChange(); + performBulkStatusChange(target); break; case 'close-bulk-status-modal': closeBulkStatusModal(); break; case 'perform-bulk-close': - performBulkCloseAction(); + performBulkCloseAction(undefined, target); break; case 'close-bulk-close-modal': closeBulkCloseModal(); @@ -491,7 +491,10 @@ function closeBulkCloseModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkCloseAction(ticketIds) { +function performBulkCloseAction(ticketIds, btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests + if (btn) btn.disabled = true; + ticketIds = ticketIds || getSelectedTicketIds(); const commentEl = document.getElementById('bulkCloseComment'); const comment = commentEl ? commentEl.value.trim() : ''; @@ -524,7 +527,8 @@ function performBulkCloseAction(ticketIds) { } closeBulkCloseModal(); lt.toast.error('Bulk close failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } var _bulkAssignUserId = null; @@ -596,7 +600,8 @@ function closeBulkAssignModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkAssign() { +function performBulkAssign(btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests const userId = _bulkAssignUserId; const ticketIds = getSelectedTicketIds(); @@ -605,6 +610,8 @@ function performBulkAssign() { return; } + if (btn) btn.disabled = true; + lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_assign', ticket_ids: ticketIds, @@ -625,7 +632,8 @@ function performBulkAssign() { }) .catch(error => { lt.toast.error('Bulk assign failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } function showBulkPriorityModal() { @@ -672,7 +680,8 @@ function closeBulkPriorityModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkPriority() { +function performBulkPriority(btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests const priorityEl = document.getElementById('bulkPriority'); if (!priorityEl) return; const priority = priorityEl.value; @@ -683,6 +692,8 @@ function performBulkPriority() { return; } + if (btn) btn.disabled = true; + lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_priority', ticket_ids: ticketIds, @@ -703,7 +714,8 @@ function performBulkPriority() { }) .catch(error => { lt.toast.error('Bulk priority update failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } // Make table rows clickable @@ -786,7 +798,8 @@ function closeBulkStatusModal() { if (modal) setTimeout(() => modal.remove(), 300); } -function performBulkStatusChange() { +function performBulkStatusChange(btn) { + if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests const bulkStatusEl = document.getElementById('bulkStatus'); if (!bulkStatusEl) return; const status = bulkStatusEl.value; @@ -800,6 +813,8 @@ function performBulkStatusChange() { const commentEl = document.getElementById('bulkStatusComment'); const comment = commentEl ? commentEl.value.trim() : ''; + if (btn) btn.disabled = true; + lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_status', ticket_ids: ticketIds, @@ -829,7 +844,8 @@ function performBulkStatusChange() { } closeBulkStatusModal(); lt.toast.error('Bulk status change failed: ' + error.message, 5000); - }); + }) + .finally(() => { if (btn) btn.disabled = false; }); } /**