Implement bulk_delete operation; validate operation types

- TicketModel: add deleteTicket() that removes all child records
  (comments, watchers, dependencies, attachments, custom fields)
  then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
  so the "Bulk Delete" UI button actually works instead of silently
  failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
  reject unknown operations early with a proper error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 12:53:53 -04:00
parent d295d64f85
commit ab0edd1325
3 changed files with 80 additions and 1 deletions
+2 -1
View File
@@ -44,7 +44,8 @@ $ticketIds = $data['ticket_ids'] ?? [];
$parameters = $data['parameters'] ?? null; $parameters = $data['parameters'] ?? null;
// Validate input // Validate input
if (!$operationType || empty($ticketIds)) { $validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']); echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
exit; exit;
} }
+8
View File
@@ -173,6 +173,14 @@ class BulkOperationsModel {
} }
} }
break; break;
case 'bulk_delete':
$success = $ticketModel->deleteTicket($ticketId);
if ($success) {
$auditLogModel->log($operation['performed_by'], 'delete', 'ticket', $ticketId,
['bulk_operation_id' => $operationId]);
}
break;
} }
if ($success) { if ($success) {
+70
View File
@@ -716,6 +716,76 @@ class TicketModel {
return $result; return $result;
} }
/**
* Delete a ticket and all its associated records.
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
*
* @param string $ticketId Ticket ID
* @return bool Success status
*/
public function deleteTicket(string $ticketId): bool {
// Collect attachment filenames before deleting DB rows
$attachmentFiles = [];
$attStmt = $this->conn->prepare("SELECT filename FROM ticket_attachments WHERE ticket_id = ?");
if ($attStmt) {
$attStmt->bind_param('s', $ticketId);
$attStmt->execute();
$attResult = $attStmt->get_result();
while ($row = $attResult->fetch_assoc()) {
$attachmentFiles[] = $row['filename'];
}
$attStmt->close();
}
// Delete child records first to avoid FK constraint failures
$children = [
"DELETE FROM ticket_comments WHERE ticket_id = ?",
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
];
foreach ($children as $sql) {
$stmt = $this->conn->prepare($sql);
if (!$stmt) continue;
// ticket_dependencies uses two placeholders
if (strpos($sql, 'depends_on_id') !== false) {
$stmt->bind_param('ss', $ticketId, $ticketId);
} else {
$stmt->bind_param('s', $ticketId);
}
$stmt->execute();
$stmt->close();
}
$stmt = $this->conn->prepare("DELETE FROM tickets WHERE ticket_id = ?");
if (!$stmt) return false;
$stmt->bind_param('s', $ticketId);
$result = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($result && $affected > 0) {
// Clean up physical attachment files
$uploadDir = defined('UPLOAD_DIR')
? UPLOAD_DIR
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
if (is_dir($ticketDir)) {
foreach ($attachmentFiles as $filename) {
$file = $ticketDir . '/' . basename($filename);
if (file_exists($file)) {
@unlink($file);
}
}
@rmdir($ticketDir); // Remove dir only if empty
}
return true;
}
return false;
}
/** /**
* Check whether the FULLTEXT index on tickets(title, description) exists. * Check whether the FULLTEXT index on tickets(title, description) exists.
* Result is cached for the process lifetime (static). * Result is cached for the process lifetime (static).