diff --git a/api/bulk_operation.php b/api/bulk_operation.php index 4295099..b892262 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -44,7 +44,8 @@ $ticketIds = $data['ticket_ids'] ?? []; $parameters = $data['parameters'] ?? null; // 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']); exit; } diff --git a/models/BulkOperationsModel.php b/models/BulkOperationsModel.php index 1717e1d..8a4419e 100644 --- a/models/BulkOperationsModel.php +++ b/models/BulkOperationsModel.php @@ -173,6 +173,14 @@ class BulkOperationsModel { } } 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) { diff --git a/models/TicketModel.php b/models/TicketModel.php index 7a1f7fc..d2a418a 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -716,6 +716,76 @@ class TicketModel { 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. * Result is cached for the process lifetime (static).