BulkOperationsModel's bulk_close/bulk_status paths had zero references
to NotificationHelper — the exact same status transition (e.g.
Open->Closed) silently produced no Matrix/watcher notification when
performed via bulk actions, while the single-ticket edit page and
Bearer API both notify on every status change. Separately, their
audit_log entries used a bare ['status' => 'Closed', ...] shape
instead of the {'status': {'from': X, 'to': Y}} shape every other
status-change path uses, which broke two downstream consumers:
TicketView.php's timeline fell back to a generic "updated this
ticket" instead of "updated status", and notifications.php's
$details['status']['from'] on a string produced a broken "? -> ?"
notification title.
Fixed the audit_log shape for both operation types, and added a
notification queue collected during the per-ticket loop and flushed
only after a successful commit (so atomic-mode rollback correctly
sends zero notifications, matching how nothing else about a rolled-
back batch takes effect either). Also fixed an incidental bug found
while matching this to the single-ticket path: update_ticket.php's
notifyWatchers() call never passed the ticket's visibility, silently
defaulting to 'public' and always including the shared notify list
even for confidential/internal tickets — the exact leak #71 fixed
elsewhere in NotificationHelper itself, just never reaching this
call site.
Verified against real MariaDB with a real local webhook-capturing
server: bulk_close correctly fires sendStatusChangeNotification() +
notifyWatchers() with the right old/new status and a redacted title
for a confidential ticket; audit_log rows show the correct {from,to}
shape; and an atomic-mode rollback (one ticket's transition invalid)
sends zero notifications and leaves both tickets unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
This commit is contained in:
@@ -276,7 +276,8 @@ try {
|
|||||||
$updateData['title'],
|
$updateData['title'],
|
||||||
'status_changed',
|
'status_changed',
|
||||||
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
||||||
(int)$this->userId
|
(int)$this->userId,
|
||||||
|
$currentTicket['visibility'] ?? 'public'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,13 +125,23 @@ class BulkOperationsModel
|
|||||||
$processed = 0;
|
$processed = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
// Status-change notifications collected during the loop below and
|
||||||
|
// sent only after a successful commit, matching how the single-ticket
|
||||||
|
// and Bearer API paths never notify for a change that didn't durably
|
||||||
|
// land (and how an atomic-mode rollback must not fire any at all).
|
||||||
|
$notificationQueue = [];
|
||||||
|
|
||||||
// Load required models
|
// Load required models
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
$ticketModel = new TicketModel($this->conn);
|
$ticketModel = new TicketModel($this->conn);
|
||||||
$auditLogModel = new AuditLogModel($this->conn);
|
$auditLogModel = new AuditLogModel($this->conn);
|
||||||
|
$userModel = new UserModel($this->conn);
|
||||||
|
$actor = $operation['performed_by'] ? $userModel->getUserById((int)$operation['performed_by']) : null;
|
||||||
|
$changedByDisplay = $actor['display_name'] ?? $actor['username'] ?? null;
|
||||||
|
|
||||||
// Batch load all tickets in one query to eliminate N+1 problem
|
// Batch load all tickets in one query to eliminate N+1 problem
|
||||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
@@ -221,8 +231,18 @@ class BulkOperationsModel
|
|||||||
'update',
|
'update',
|
||||||
'ticket',
|
'ticket',
|
||||||
$ticketId,
|
$ticketId,
|
||||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]
|
[
|
||||||
|
'status' => ['from' => $currentTicket['status'], 'to' => 'Closed'],
|
||||||
|
'bulk_operation_id' => $operationId,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
$notificationQueue[] = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'title' => $currentTicket['title'],
|
||||||
|
'visibility' => $currentTicket['visibility'] ?? 'public',
|
||||||
|
'oldStatus' => $currentTicket['status'],
|
||||||
|
'newStatus' => 'Closed',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -291,8 +311,18 @@ class BulkOperationsModel
|
|||||||
'update',
|
'update',
|
||||||
'ticket',
|
'ticket',
|
||||||
$ticketId,
|
$ticketId,
|
||||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
|
[
|
||||||
|
'status' => ['from' => $currentTicket['status'], 'to' => $parameters['status']],
|
||||||
|
'bulk_operation_id' => $operationId,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
$notificationQueue[] = [
|
||||||
|
'ticketId' => $ticketId,
|
||||||
|
'title' => $currentTicket['title'],
|
||||||
|
'visibility' => $currentTicket['visibility'] ?? 'public',
|
||||||
|
'oldStatus' => $currentTicket['status'],
|
||||||
|
'newStatus' => $parameters['status'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +394,35 @@ class BulkOperationsModel
|
|||||||
@unlink($path);
|
@unlink($path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire the same Matrix/watcher notifications the single-ticket and
|
||||||
|
// Bearer API status-change paths send, now that every change in
|
||||||
|
// this batch is durably committed. Best-effort: a notification
|
||||||
|
// failure must never turn an otherwise-successful bulk operation
|
||||||
|
// into an error.
|
||||||
|
foreach ($notificationQueue as $n) {
|
||||||
|
try {
|
||||||
|
NotificationHelper::sendStatusChangeNotification(
|
||||||
|
$n['ticketId'],
|
||||||
|
$n['oldStatus'],
|
||||||
|
$n['newStatus'],
|
||||||
|
$n['title'],
|
||||||
|
$changedByDisplay,
|
||||||
|
$n['visibility']
|
||||||
|
);
|
||||||
|
NotificationHelper::notifyWatchers(
|
||||||
|
$this->conn,
|
||||||
|
$n['ticketId'],
|
||||||
|
$n['title'],
|
||||||
|
'status_changed',
|
||||||
|
['old_status' => $n['oldStatus'], 'new_status' => $n['newStatus'], 'changed_by' => $changedByDisplay],
|
||||||
|
(int)$operation['performed_by'],
|
||||||
|
$n['visibility']
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Bulk operation $operationId: notification failed for ticket {$n['ticketId']}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Rollback on any unexpected error
|
// Rollback on any unexpected error
|
||||||
$this->conn->rollback();
|
$this->conn->rollback();
|
||||||
|
|||||||
Reference in New Issue
Block a user