From d443caf0593e3936c15faea907ff6d0e62ecb3b6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Apr 2026 22:29:14 -0400 Subject: [PATCH 1/4] Fix bulk operation dropping tickets with leading-zero IDs, add query null-check bulk_operation.php: ticket ID validation was converting IDs to int then back to string, so '000123456' became '123456' which never matched the DB VARCHAR key, silently rejecting ~11% of tickets from bulk operations. Now validates with ctype_digit() to preserve leading zeros. TicketModel::getTicketsByIds(): changed intval() to strval() and bind type 'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings. DashboardController::getCategoriesAndTypes(): added null check on query result before calling fetch_assoc() to prevent TypeError if query fails. Co-Authored-By: Claude Sonnet 4.6 --- api/bulk_operation.php | 6 +++--- controllers/DashboardController.php | 4 ++++ models/TicketModel.php | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/api/bulk_operation.php b/api/bulk_operation.php index 0d42085..4295099 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -49,10 +49,10 @@ if (!$operationType || empty($ticketIds)) { exit; } -// Validate ticket IDs are positive integers +// Validate ticket IDs: must be non-empty numeric strings (allows leading zeros) $ticketIds = array_values(array_filter(array_map(function($id) { - $int = (int)$id; - return ($int > 0 && (string)$int === (string)$id) ? $int : null; + $s = trim((string)$id); + return (ctype_digit($s) && (int)$s > 0) ? $s : null; }, $ticketIds))); if (empty($ticketIds)) { echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']); diff --git a/controllers/DashboardController.php b/controllers/DashboardController.php index caa02d5..0b2908e 100644 --- a/controllers/DashboardController.php +++ b/controllers/DashboardController.php @@ -186,6 +186,10 @@ class DashboardController { $categories = []; $types = []; + if (!$result) { + return ['categories' => $categories, 'types' => $types]; + } + while ($row = $result->fetch_assoc()) { if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) { $categories[] = $row['value']; diff --git a/models/TicketModel.php b/models/TicketModel.php index 31821d0..7a1f7fc 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -558,8 +558,8 @@ class TicketModel { return []; } - // Sanitize ticket IDs - $ticketIds = array_map('intval', $ticketIds); + // Sanitize ticket IDs: cast to string to preserve leading zeros + $ticketIds = array_map('strval', $ticketIds); // Create placeholders for IN clause $placeholders = str_repeat('?,', count($ticketIds) - 1) . '?'; @@ -578,7 +578,7 @@ class TicketModel { WHERE t.ticket_id IN ($placeholders)"; $stmt = $this->conn->prepare($sql); - $types = str_repeat('i', count($ticketIds)); + $types = str_repeat('s', count($ticketIds)); $stmt->bind_param($types, ...$ticketIds); $stmt->execute(); $result = $stmt->get_result(); From a3fbad19c9bf86b883fbf9a74f328c5c14589519 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 11 Apr 2026 12:43:18 -0400 Subject: [PATCH 2/4] Fix leading-zero ticket ID handling across API and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard.js: use String(cb.value) instead of parseInt() in getSelectedTicketIds() so zero-padded IDs like 000123456 are preserved when sent to bulk_operation.php - DashboardView.php: remove (int) cast on data-ticket-id attribute for quick-status button; was stripping leading zeros - TicketView.php: remove (int) cast on export URL ticket_id param - update_ticket.php: preserve ticket_id as string via trim((string)...) - add_comment.php: preserve ticket_id as string; validate with ctype_digit instead of (int) cast so comments are stored with the canonical zero-padded ID matching the tickets table - export_tickets.php: validate singleId as string to avoid stripping leading zeros in the export endpoint - notifications.php: preserve ticket_id strings in URLs and ticket ownership checks; index myTicketIds by both int and string forms for robust lookup regardless of how audit_log stored the ID - TicketController.php: fix inline dependency insert — column was wrong (depends_on_ticket_id → depends_on_id) and bind types were wrong ("iii" → "ssi"); feature was silently broken Co-Authored-By: Claude Sonnet 4.6 --- api/add_comment.php | 4 ++-- api/export_tickets.php | 3 ++- api/notifications.php | 13 +++++++------ api/update_ticket.php | 2 +- assets/js/dashboard.js | 2 +- controllers/TicketController.php | 8 ++++---- views/DashboardView.php | 2 +- views/TicketView.php | 2 +- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/api/add_comment.php b/api/add_comment.php index 8737dae..cc306c5 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -65,8 +65,8 @@ try { throw new Exception("Invalid JSON data received"); } - $ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0; - if ($ticketId <= 0) { + $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; + if (!ctype_digit($ticketId) || (int)$ticketId <= 0) { http_response_code(400); ob_end_clean(); header('Content-Type: application/json'); diff --git a/api/export_tickets.php b/api/export_tickets.php index 5c8d1b7..e6eec31 100644 --- a/api/export_tickets.php +++ b/api/export_tickets.php @@ -43,7 +43,8 @@ try { $search = isset($_GET['search']) ? trim($_GET['search']) : null; $format = isset($_GET['format']) ? $_GET['format'] : 'csv'; $ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null; - $singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : null; + $singleIdRaw = isset($_GET['ticket_id']) ? trim($_GET['ticket_id']) : null; + $singleId = ($singleIdRaw !== null && ctype_digit($singleIdRaw) && (int)$singleIdRaw > 0) ? $singleIdRaw : null; // Initialize model $ticketModel = new TicketModel($conn); diff --git a/api/notifications.php b/api/notifications.php index f3afd22..b32a21e 100644 --- a/api/notifications.php +++ b/api/notifications.php @@ -75,7 +75,7 @@ $stmt = $conn->prepare($myTicketsSql); $stmt->bind_param('ii', $userId, $userId); $stmt->execute(); $mtResult = $stmt->get_result(); -while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; } +while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; $myTicketIds[$mtRow['ticket_id']] = true; } $stmt->close(); $watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?"; @@ -83,7 +83,7 @@ $stmt = $conn->prepare($watchedSql); $stmt->bind_param('i', $userId); $stmt->execute(); $wResult = $stmt->get_result(); -while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; } +while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; $myTicketIds[$wRow['ticket_id']] = true; } $stmt->close(); // Step B: fetch recent comment audit events not by the current user @@ -109,8 +109,9 @@ $stmt->close(); $commentRows = []; foreach ($rawCommentRows as $rawRow) { $d = json_decode($rawRow['details'] ?? '{}', true) ?? []; - $tid = (int)($d['ticket_id'] ?? 0); - if ($tid > 0 && isset($myTicketIds[$tid])) { + $tidRaw = $d['ticket_id'] ?? 0; + $tid = (int)$tidRaw; + if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) { $commentRows[] = $rawRow; if (count($commentRows) >= 15) break; } @@ -158,8 +159,8 @@ foreach ($all as $row) { ? 'comment' : $row['action_type']; $ticketId = ($actionType === 'comment') - ? (int)($details['ticket_id'] ?? 0) - : (int)$row['entity_id']; + ? ($details['ticket_id'] ?? 0) + : $row['entity_id']; $isRead = $lastSeen && $row['created_at'] <= $lastSeen; // Build human-readable title diff --git a/api/update_ticket.php b/api/update_ticket.php index ccc76bb..29c32c7 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -252,7 +252,7 @@ try { throw new Exception("Missing ticket_id parameter"); } - $ticketId = (int)$data['ticket_id']; + $ticketId = trim((string)$data['ticket_id']); // Initialize controller $controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser); diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index b81fcae..06887e5 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -497,7 +497,7 @@ function updateSelectionCount() { function getSelectedTicketIds() { const checkboxes = document.querySelectorAll('.ticket-checkbox:checked'); - return Array.from(checkboxes).map(cb => parseInt(cb.value)); + return Array.from(checkboxes).map(cb => String(cb.value)); } function clearSelection() { diff --git a/controllers/TicketController.php b/controllers/TicketController.php index 9405f89..5172336 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -126,12 +126,12 @@ class TicketController { } // Auto-link as duplicate if requested from create form - $linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0; - if ($linkDupOf > 0) { - $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by) + $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); + if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { + $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by) VALUES (?, ?, 'duplicates', ?)"; $depStmt = $this->conn->prepare($depSql); - $depStmt->bind_param("iii", $result['ticket_id'], $linkDupOf, $userId); + $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId); $depStmt->execute(); $depStmt->close(); } diff --git a/views/DashboardView.php b/views/DashboardView.php index 79a3251..50a7991 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -762,7 +762,7 @@ include __DIR__ . '/layout_header.php'; aria-label="View ticket ">View EXPORT From f93cebe2d93c9e2e62cc67adf02f20c0181a073a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 11 Apr 2026 12:53:53 -0400 Subject: [PATCH 3/4] 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 --- api/bulk_operation.php | 3 +- models/BulkOperationsModel.php | 8 ++++ models/TicketModel.php | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) 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). From 63092ac070b6a6a18299ac963440bcaffaed1ad4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 11 Apr 2026 13:02:49 -0400 Subject: [PATCH 4/4] Fix leading-zero ticket ID in clone_ticket.php - Preserve source ticket ID as string (ctype_digit validation) instead of int-casting with (int) - Use $sourceTicket['ticket_id'] (canonical DB form) when creating the relates_to dependency and audit log entry; avoids storing "123456" instead of "000123456" which breaks string-based depends_on_id lookups in DependencyModel Co-Authored-By: Claude Sonnet 4.6 --- api/clone_ticket.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/clone_ticket.php b/api/clone_ticket.php index ecb1969..4d900cd 100644 --- a/api/clone_ticket.php +++ b/api/clone_ticket.php @@ -54,12 +54,13 @@ try { exit; } - $sourceTicketId = (int)$data['ticket_id']; - if ($sourceTicketId <= 0) { + $sourceTicketIdRaw = trim((string)$data['ticket_id']); + if (!ctype_digit($sourceTicketIdRaw) || (int)$sourceTicketIdRaw <= 0) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']); exit; } + $sourceTicketId = $sourceTicketIdRaw; $userId = $_SESSION['user']['user_id']; $isAdmin = $_SESSION['user']['is_admin'] ?? false; @@ -102,14 +103,14 @@ try { $auditLog = new AuditLogModel($conn); $auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [ 'action' => 'clone', - 'source_ticket_id' => $sourceTicketId, + 'source_ticket_id' => $sourceTicket['ticket_id'], 'title' => $clonedTicketData['title'] ]); // Optionally create a "relates_to" dependency require_once dirname(__DIR__) . '/models/DependencyModel.php'; $dependencyModel = new DependencyModel($conn); - $dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId); + $dependencyModel->addDependency($result['ticket_id'], $sourceTicket['ticket_id'], 'relates_to', $userId); require_once dirname(__DIR__) . '/models/StatsModel.php'; (new StatsModel($conn))->invalidateCache();