From 9d982ab73f9fb1acbff61350c482f186e23603d0 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 7 Aug 2026 22:35:36 -0400 Subject: [PATCH 1/2] Bulk status/close: enforce Workflow Designer rules (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk status changes previously bypassed the workflow entirely — the model carried an explicit "admin-only escape hatch" note — so bulk edit could drive tickets through transitions the designer forbids and skip comments the designer requires. BulkOperationsModel now applies the same rules as the single-ticket path: - Transitions absent from status_transitions are refused per ticket and reported with a reason, instead of being forced through. - requires_comment is checked up front across the whole selection, so a batch is rejected before any ticket is mutated rather than half-applied. - The reason is persisted as a comment on each ticket changed, matching what a single-ticket close records. - Tickets already in the target status are a no-op success, not a failure. requires_admin needs no extra check: api/bulk_operation.php already gates the endpoint on admin. Client: both bulk modals now collect a reason, the close path gets a real modal instead of a bare confirm, and per-ticket skip reasons surface in the result toast instead of a bare failure count. --- api/bulk_operation.php | 11 ++- assets/js/dashboard.js | 103 +++++++++++++++++++++++--- models/BulkOperationsModel.php | 128 +++++++++++++++++++++++++++++++-- 3 files changed, 225 insertions(+), 17 deletions(-) diff --git a/api/bulk_operation.php b/api/bulk_operation.php index f25f73c..fe4f521 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -107,10 +107,17 @@ $result = $bulkOpsModel->processBulkOperation($operationId); if (isset($result['error'])) { $conn->close(); - echo json_encode([ + $response = [ 'success' => false, 'error' => $result['error'] - ]); + ]; + // Let the client know it should collect a comment and retry, rather than + // showing the failure as a dead end. + if (!empty($result['requires_comment'])) { + $response['requires_comment'] = true; + http_response_code(400); + } + echo json_encode($response); } else { // Invalidate stats cache so dashboard tiles reflect changes immediately require_once dirname(__DIR__) . '/models/StatsModel.php'; diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 526ef54..f0e64c9 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() { case 'close-bulk-status-modal': closeBulkStatusModal(); break; + case 'perform-bulk-close': + performBulkCloseAction(); + break; + case 'close-bulk-close-modal': + closeBulkCloseModal(); + break; case 'perform-bulk-delete': performBulkDelete(); break; @@ -515,24 +521,59 @@ function bulkClose() { return; } - showConfirmModal( - `Close ${ticketIds.length} Ticket(s)?`, - 'Are you sure you want to close these tickets?', - 'warning', - () => performBulkCloseAction(ticketIds) - ); + // Closing needs a reason: the default workflow marks every → Closed transition + // requires_comment, so collect it here instead of failing server-side. + const modalHtml = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + lt.modal.open('bulkCloseModal'); +} + +function closeBulkCloseModal() { + lt.modal.close('bulkCloseModal'); + const modal = document.getElementById('bulkCloseModal'); + if (modal) setTimeout(() => modal.remove(), 300); } function performBulkCloseAction(ticketIds) { + ticketIds = ticketIds || getSelectedTicketIds(); + const commentEl = document.getElementById('bulkCloseComment'); + const comment = commentEl ? commentEl.value.trim() : ''; lt.api.post('/api/bulk_operation.php', { operation_type: 'bulk_close', - ticket_ids: ticketIds + ticket_ids: ticketIds, + parameters: { comment: comment } }) .then(data => { + closeBulkCloseModal(); if (data.success) { if (data.failed > 0) { - lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000); + lt.toast.warning(bulkResultMessage('Bulk close', data), 6000); } else { lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000); } @@ -542,6 +583,14 @@ function performBulkCloseAction(ticketIds) { } }) .catch(error => { + // Missing required comment — keep the modal open so it can be entered. + if (error && error.data && error.data.requires_comment) { + lt.toast.warning(error.data.error || 'A close reason is required', 6000); + const ta = document.getElementById('bulkCloseComment'); + if (ta) ta.focus(); + return; + } + closeBulkCloseModal(); lt.toast.error('Bulk close failed: ' + error.message, 5000); }); } @@ -777,6 +826,15 @@ function showBulkStatusModal() { ${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => ``).join('')} + + +

+ Required for transitions the Workflow Designer marks as needing a comment + (e.g. closing a ticket). Posted as a comment on every ticket changed. +