diff --git a/api/add_comment.php b/api/add_comment.php index 4fc53f2..ec4ce0d 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -27,6 +27,7 @@ try { require_once $configPath; require_once $commentModelPath; require_once $auditLogModelPath; + require_once dirname(__DIR__) . '/helpers/Database.php'; // Check authentication via session session_start(); @@ -49,17 +50,8 @@ try { $currentUser = $_SESSION['user']; $userId = $currentUser['user_id']; - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed: " . $conn->connect_error); - } + // Use centralized database connection + $conn = Database::getConnection(); // Get POST data $data = json_decode(file_get_contents('php://input'), true); diff --git a/api/assign_ticket.php b/api/assign_ticket.php index ec07c58..786c450 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -5,6 +5,7 @@ RateLimitMiddleware::apply('api'); session_start(); require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php'; @@ -40,18 +41,8 @@ if (!$ticketId) { exit; } -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; -} +// Use centralized database connection +$conn = Database::getConnection(); $ticketModel = new TicketModel($conn); $auditLogModel = new AuditLogModel($conn); @@ -69,7 +60,6 @@ if ($assignedTo === null || $assignedTo === '') { $targetUser = $userModel->getUserById($assignedTo); if (!$targetUser) { echo json_encode(['success' => false, 'error' => 'Invalid user ID']); - $conn->close(); exit; } @@ -80,6 +70,4 @@ if ($assignedTo === null || $assignedTo === '') { } } -$conn->close(); - echo json_encode(['success' => $success]); diff --git a/api/audit_log.php b/api/audit_log.php index 67fc39b..9861f00 100644 --- a/api/audit_log.php +++ b/api/audit_log.php @@ -6,6 +6,7 @@ */ require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; session_start(); @@ -26,19 +27,8 @@ if (!$isAdmin) { exit; } -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; -} +// Use centralized database connection +$conn = Database::getConnection(); $auditLogModel = new AuditLogModel($conn); @@ -90,7 +80,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { } fclose($output); - $conn->close(); exit; } diff --git a/api/bulk_operation.php b/api/bulk_operation.php index 8cd2c64..25a9e5d 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -5,6 +5,7 @@ RateLimitMiddleware::apply('api'); session_start(); require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/BulkOperationsModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; @@ -55,21 +56,8 @@ foreach ($ticketIds as $ticketId) { } } -// Create database connection (needed for visibility check) -require_once dirname(__DIR__) . '/models/TicketModel.php'; - -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; -} +// Use centralized database connection +$conn = Database::getConnection(); $bulkOpsModel = new BulkOperationsModel($conn); $ticketModel = new TicketModel($conn); @@ -92,7 +80,6 @@ foreach ($ticketIds as $ticketId) { } if (empty($accessibleTicketIds)) { - $conn->close(); echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']); exit; } @@ -104,7 +91,6 @@ $ticketIds = $accessibleTicketIds; $operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters); if (!$operationId) { - $conn->close(); echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']); exit; } diff --git a/api/check_duplicates.php b/api/check_duplicates.php index 3dbae94..ef30852 100644 --- a/api/check_duplicates.php +++ b/api/check_duplicates.php @@ -11,6 +11,7 @@ RateLimitMiddleware::apply('api'); session_start(); require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; header('Content-Type: application/json'); @@ -32,17 +33,8 @@ if (strlen($title) < 5) { ResponseHelper::success(['duplicates' => []]); } -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - ResponseHelper::serverError('Database connection failed'); -} +// Use centralized database connection +$conn = Database::getConnection(); // Search for similar titles // Use both LIKE for substring matching and SOUNDEX for phonetic matching @@ -112,6 +104,4 @@ usort($duplicates, function($a, $b) { // Limit to top 5 $duplicates = array_slice($duplicates, 0, 5); -$conn->close(); - ResponseHelper::success(['duplicates' => $duplicates]); diff --git a/api/custom_fields.php b/api/custom_fields.php index 4710e34..8b08e7c 100644 --- a/api/custom_fields.php +++ b/api/custom_fields.php @@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api'); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/CustomFieldModel.php'; // Check authentication @@ -40,16 +41,8 @@ try { } } - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); header('Content-Type: application/json'); @@ -103,8 +96,6 @@ try { echo json_encode(['success' => false, 'error' => 'Method not allowed']); } - $conn->close(); - } catch (Exception $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => $e->getMessage()]); diff --git a/api/delete_attachment.php b/api/delete_attachment.php index 5ce1f3d..4959c43 100644 --- a/api/delete_attachment.php +++ b/api/delete_attachment.php @@ -19,6 +19,7 @@ if (session_status() === PHP_SESSION_NONE) { } require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; @@ -87,27 +88,19 @@ try { } // Log the deletion - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] + $conn = Database::getConnection(); + $auditLog = new AuditLogModel($conn); + $auditLog->log( + $_SESSION['user']['user_id'], + 'attachment_delete', + 'ticket_attachments', + (string)$attachmentId, + [ + 'ticket_id' => $attachment['ticket_id'], + 'filename' => $attachment['original_filename'], + 'size' => $attachment['file_size'] + ] ); - if (!$conn->connect_error) { - $auditLog = new AuditLogModel($conn); - $auditLog->log( - $_SESSION['user']['user_id'], - 'attachment_delete', - 'ticket_attachments', - (string)$attachmentId, - [ - 'ticket_id' => $attachment['ticket_id'], - 'filename' => $attachment['original_filename'], - 'size' => $attachment['file_size'] - ] - ); - $conn->close(); - } ResponseHelper::success([], 'Attachment deleted successfully'); diff --git a/api/delete_comment.php b/api/delete_comment.php index 6c93914..386c3ce 100644 --- a/api/delete_comment.php +++ b/api/delete_comment.php @@ -16,6 +16,7 @@ ob_start(); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/CommentModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; @@ -39,17 +40,8 @@ try { $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); // Get data - support both POST body and query params $data = json_decode(file_get_contents('php://input'), true); diff --git a/api/download_attachment.php b/api/download_attachment.php index 883362f..012191e 100644 --- a/api/download_attachment.php +++ b/api/download_attachment.php @@ -7,6 +7,7 @@ session_start(); require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; @@ -42,24 +43,12 @@ try { } // Verify the associated ticket exists and user has access - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - if ($conn->connect_error) { - http_response_code(500); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; - } + $conn = Database::getConnection(); $ticketModel = new TicketModel($conn); $ticket = $ticketModel->getTicketById($attachment['ticket_id']); if (!$ticket) { - $conn->close(); http_response_code(404); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Associated ticket not found']); @@ -68,7 +57,6 @@ try { // Check if user has access to this ticket based on visibility settings if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { - $conn->close(); http_response_code(403); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']); diff --git a/api/export_tickets.php b/api/export_tickets.php index 36b0d44..30d1d05 100644 --- a/api/export_tickets.php +++ b/api/export_tickets.php @@ -3,6 +3,7 @@ * Export Tickets API * * Exports tickets to CSV format with optional filtering + * Respects ticket visibility settings */ // Disable error display in the output @@ -16,6 +17,7 @@ RateLimitMiddleware::apply('api'); try { // Include required files require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; // Check authentication via session @@ -29,17 +31,8 @@ try { $currentUser = $_SESSION['user']; - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); // Get filter parameters $status = isset($_GET['status']) ? $_GET['status'] : null; @@ -64,11 +57,18 @@ try { } // Get specific tickets by IDs - $tickets = $ticketModel->getTicketsByIds($ticketIdArray); - // Convert associative array to indexed array - $tickets = array_values($tickets); + $allTickets = $ticketModel->getTicketsByIds($ticketIdArray); + + // Filter tickets based on visibility - only export tickets the user can access + $tickets = []; + foreach ($allTickets as $ticket) { + if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) { + $tickets[] = $ticket; + } + } } else { // Get all tickets with filters (no pagination for export) + // getAllTickets already applies visibility filtering via getVisibilityFilter $result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search); $tickets = $result['tickets']; } diff --git a/api/generate_api_key.php b/api/generate_api_key.php index 91c7e8a..7b9baa0 100644 --- a/api/generate_api_key.php +++ b/api/generate_api_key.php @@ -12,6 +12,7 @@ ob_start(); try { // Load config require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; // Load models require_once dirname(__DIR__) . '/models/ApiKeyModel.php'; @@ -71,17 +72,8 @@ try { $expiresInDays = null; } - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); // Generate API key $apiKeyModel = new ApiKeyModel($conn); @@ -101,8 +93,6 @@ try { ['key_name' => $keyName, 'expires_in_days' => $expiresInDays] ); - $conn->close(); - // Clear output buffer ob_end_clean(); diff --git a/api/health.php b/api/health.php new file mode 100644 index 0000000..c7892b3 --- /dev/null +++ b/api/health.php @@ -0,0 +1,110 @@ +query('SELECT 1'); + if ($result && $result->fetch_row()) { + $checks['database'] = [ + 'status' => 'ok', + 'message' => 'Connected' + ]; + } else { + $checks['database'] = [ + 'status' => 'error', + 'message' => 'Query failed' + ]; + $healthy = false; + } +} catch (Exception $e) { + $checks['database'] = [ + 'status' => 'error', + 'message' => 'Connection failed' + ]; + $healthy = false; +} + +// Check 2: File system (uploads directory writable) +$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; +if (is_dir($uploadDir) && is_writable($uploadDir)) { + $checks['filesystem'] = [ + 'status' => 'ok', + 'message' => 'Writable' + ]; +} else { + $checks['filesystem'] = [ + 'status' => 'warning', + 'message' => 'Upload directory not writable' + ]; + // Don't mark as unhealthy - this might be intentional +} + +// Check 3: Session storage +$sessionPath = session_save_path() ?: sys_get_temp_dir(); +if (is_dir($sessionPath) && is_writable($sessionPath)) { + $checks['sessions'] = [ + 'status' => 'ok', + 'message' => 'Writable' + ]; +} else { + $checks['sessions'] = [ + 'status' => 'error', + 'message' => 'Session storage not writable' + ]; + $healthy = false; +} + +// Check 4: Rate limit storage +$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit'; +if (!is_dir($rateLimitDir)) { + @mkdir($rateLimitDir, 0755, true); +} +if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) { + $checks['rate_limit'] = [ + 'status' => 'ok', + 'message' => 'Writable' + ]; +} else { + $checks['rate_limit'] = [ + 'status' => 'warning', + 'message' => 'Rate limit storage not writable' + ]; +} + +// Calculate response time +$responseTime = round((microtime(true) - $startTime) * 1000, 2); + +// Set status code +http_response_code($healthy ? 200 : 503); + +// Return response +echo json_encode([ + 'status' => $healthy ? 'healthy' : 'unhealthy', + 'timestamp' => date('c'), + 'response_time_ms' => $responseTime, + 'checks' => $checks, + 'version' => '1.0.0' +], JSON_PRETTY_PRINT); diff --git a/api/manage_recurring.php b/api/manage_recurring.php index 53a23f7..fed1be8 100644 --- a/api/manage_recurring.php +++ b/api/manage_recurring.php @@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api'); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/RecurringTicketModel.php'; // Check authentication @@ -42,16 +43,8 @@ try { } } - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); header('Content-Type: application/json'); @@ -130,8 +123,6 @@ try { echo json_encode(['success' => false, 'error' => 'Method not allowed']); } - $conn->close(); - } catch (Exception $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => $e->getMessage()]); diff --git a/api/manage_templates.php b/api/manage_templates.php index cff7c97..0690cb2 100644 --- a/api/manage_templates.php +++ b/api/manage_templates.php @@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api'); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; // Check authentication session_start(); @@ -39,16 +40,8 @@ try { } } - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); header('Content-Type: application/json'); @@ -145,8 +138,6 @@ try { echo json_encode(['success' => false, 'error' => 'Method not allowed']); } - $conn->close(); - } catch (Exception $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => $e->getMessage()]); diff --git a/api/manage_workflows.php b/api/manage_workflows.php index 6e97ffd..558862d 100644 --- a/api/manage_workflows.php +++ b/api/manage_workflows.php @@ -13,6 +13,8 @@ RateLimitMiddleware::apply('api'); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; + require_once dirname(__DIR__) . '/models/AuditLogModel.php'; // Check authentication session_start(); @@ -40,16 +42,12 @@ try { } } - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); + // Use centralized database connection + $conn = Database::getConnection(); - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Initialize audit log + $auditLog = new AuditLogModel($conn); + $userId = $_SESSION['user']['user_id']; header('Content-Type: application/json'); @@ -92,8 +90,18 @@ try { ); if ($stmt->execute()) { + $transitionId = $conn->insert_id; WorkflowModel::clearCache(); // Clear workflow cache - echo json_encode(['success' => true, 'transition_id' => $conn->insert_id]); + + // Audit log: workflow transition created + $auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [ + 'from_status' => $data['from_status'], + 'to_status' => $data['to_status'], + 'requires_comment' => $data['requires_comment'] ?? 0, + 'requires_admin' => $data['requires_admin'] ?? 0 + ]); + + echo json_encode(['success' => true, 'transition_id' => $transitionId]); } else { echo json_encode(['success' => false, 'error' => $stmt->error]); } @@ -123,6 +131,14 @@ try { $success = $stmt->execute(); if ($success) { WorkflowModel::clearCache(); // Clear workflow cache + + // Audit log: workflow transition updated + $auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [ + 'from_status' => $data['from_status'], + 'to_status' => $data['to_status'], + 'requires_comment' => $data['requires_comment'] ?? 0, + 'requires_admin' => $data['requires_admin'] ?? 0 + ]); } echo json_encode(['success' => $success]); $stmt->close(); @@ -134,11 +150,25 @@ try { exit; } + // Get transition details before deletion for audit log + $getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?"); + $getStmt->bind_param('i', $id); + $getStmt->execute(); + $getResult = $getStmt->get_result(); + $transitionData = $getResult->fetch_assoc(); + $getStmt->close(); + $stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?"); $stmt->bind_param('i', $id); $success = $stmt->execute(); if ($success) { WorkflowModel::clearCache(); // Clear workflow cache + + // Audit log: workflow transition deleted + $auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [ + 'from_status' => $transitionData['from_status'] ?? 'unknown', + 'to_status' => $transitionData['to_status'] ?? 'unknown' + ]); } echo json_encode(['success' => $success]); $stmt->close(); @@ -149,8 +179,6 @@ try { echo json_encode(['success' => false, 'error' => 'Method not allowed']); } - $conn->close(); - } catch (Exception $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => $e->getMessage()]); diff --git a/api/revoke_api_key.php b/api/revoke_api_key.php index c5d910f..fe22bb5 100644 --- a/api/revoke_api_key.php +++ b/api/revoke_api_key.php @@ -12,6 +12,7 @@ ob_start(); try { // Load config require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; // Load models require_once dirname(__DIR__) . '/models/ApiKeyModel.php'; @@ -56,17 +57,8 @@ try { throw new Exception("Valid key ID is required"); } - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); // Get key info for audit log $apiKeyModel = new ApiKeyModel($conn); @@ -97,8 +89,6 @@ try { ['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']] ); - $conn->close(); - // Clear output buffer ob_end_clean(); diff --git a/api/saved_filters.php b/api/saved_filters.php index 04450e6..08ecf76 100644 --- a/api/saved_filters.php +++ b/api/saved_filters.php @@ -5,6 +5,7 @@ */ require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/SavedFiltersModel.php'; session_start(); @@ -30,19 +31,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT $userId = $_SESSION['user']['user_id']; -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; -} +// Use centralized database connection +$conn = Database::getConnection(); $filtersModel = new SavedFiltersModel($conn); @@ -72,7 +62,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']); } - $conn->close(); exit; } @@ -83,8 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); - $conn->close(); - exit; + exit; } $filterName = trim($data['filter_name']); @@ -95,8 +83,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (empty($filterName) || strlen($filterName) > 100) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid filter name']); - $conn->close(); - exit; + exit; } try { @@ -106,7 +93,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to save filter']); } - $conn->close(); exit; } @@ -117,8 +103,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { if (!isset($data['filter_id'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_id']); - $conn->close(); - exit; + exit; } $filterId = (int)$data['filter_id']; @@ -132,16 +117,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); } - $conn->close(); - exit; + exit; } // Handle full filter update if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); - $conn->close(); - exit; + exit; } $filterName = trim($data['filter_name']); @@ -155,7 +138,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to update filter']); } - $conn->close(); exit; } @@ -166,8 +148,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { if (!isset($data['filter_id'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_id']); - $conn->close(); - exit; + exit; } $filterId = (int)$data['filter_id']; @@ -179,7 +160,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to delete filter']); } - $conn->close(); exit; } diff --git a/api/ticket_dependencies.php b/api/ticket_dependencies.php index 6434e49..743efd1 100644 --- a/api/ticket_dependencies.php +++ b/api/ticket_dependencies.php @@ -64,6 +64,7 @@ if (session_status() === PHP_SESSION_NONE) { } require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/DependencyModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; @@ -86,17 +87,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL } } -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - ResponseHelper::serverError('Database connection failed'); -} +// Use centralized database connection +$conn = Database::getConnection(); // Check if ticket_dependencies table exists $tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'"); @@ -211,6 +203,4 @@ switch ($method) { // Log detailed error server-side error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); ResponseHelper::serverError('An error occurred while processing the dependency request'); -} - -$conn->close(); +}; diff --git a/api/update_comment.php b/api/update_comment.php index 65157d9..bbcabb7 100644 --- a/api/update_comment.php +++ b/api/update_comment.php @@ -16,6 +16,7 @@ ob_start(); try { require_once dirname(__DIR__) . '/config/config.php'; + require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/CommentModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; @@ -41,17 +42,8 @@ try { $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed"); - } + // Use centralized database connection + $conn = Database::getConnection(); // Get POST/PUT data $data = json_decode(file_get_contents('php://input'), true); diff --git a/api/update_ticket.php b/api/update_ticket.php index 2bcaba7..35ac31b 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -14,6 +14,7 @@ try { // Load config $configPath = dirname(__DIR__) . '/config/config.php'; require_once $configPath; + require_once dirname(__DIR__) . '/helpers/Database.php'; // Load environment variables (for Discord webhook) $envPath = dirname(__DIR__) . '/.env'; @@ -141,11 +142,25 @@ try { } } - // Update ticket with user tracking - $result = $this->ticketModel->updateTicket($updateData, $this->userId); + // Update ticket with user tracking and optional optimistic locking + $expectedUpdatedAt = $data['expected_updated_at'] ?? null; + $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); + + // Handle conflict case + if (!$result['success']) { + $response = [ + 'success' => false, + 'error' => $result['error'] ?? 'Failed to update ticket in database' + ]; + if (!empty($result['conflict'])) { + $response['conflict'] = true; + $response['current_updated_at'] = $result['current_updated_at'] ?? null; + } + return $response; + } // Handle visibility update if provided - if ($result && isset($data['visibility'])) { + if (isset($data['visibility'])) { $visibilityGroups = $data['visibility_groups'] ?? null; // Convert array to comma-separated string if needed if (is_array($visibilityGroups)) { @@ -163,27 +178,20 @@ try { $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); } - if ($result) { - // Log ticket update to audit log - if ($this->userId) { - $this->auditLog->logTicketUpdate($this->userId, $id, $data); - } - - // Discord webhook disabled for updates - only send for new tickets - // $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data); - - return [ - 'success' => true, - 'status' => $updateData['status'], - 'priority' => $updateData['priority'], - 'message' => 'Ticket updated successfully' - ]; - } else { - return [ - 'success' => false, - 'error' => 'Failed to update ticket in database' - ]; + // Log ticket update to audit log + if ($this->userId) { + $this->auditLog->logTicketUpdate($this->userId, $id, $data); } + + // Discord webhook disabled for updates - only send for new tickets + // $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data); + + return [ + 'success' => true, + 'status' => $updateData['status'], + 'priority' => $updateData['priority'], + 'message' => 'Ticket updated successfully' + ]; } private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) { @@ -260,22 +268,18 @@ try { $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); - - // Silently handle errors - webhook is optional + + // Log webhook errors instead of silencing them + if ($curlError) { + error_log("Discord webhook cURL error for ticket #{$ticketId}: {$curlError}"); + } elseif ($httpCode !== 204 && $httpCode !== 200) { + error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}, Response: " . substr($webhookResult, 0, 200)); + } } } - // Create database connection - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] - ); - - if ($conn->connect_error) { - throw new Exception("Database connection failed: " . $conn->connect_error); - } + // Use centralized database connection + $conn = Database::getConnection(); // Check request method if ($_SERVER['REQUEST_METHOD'] !== 'POST') { @@ -302,9 +306,6 @@ try { // Update ticket $result = $controller->update($ticketId, $data); - // Close database connection - $conn->close(); - // Discard any output that might have been generated ob_end_clean(); diff --git a/api/upload_attachment.php b/api/upload_attachment.php index f7dcf74..2576ede 100644 --- a/api/upload_attachment.php +++ b/api/upload_attachment.php @@ -19,6 +19,7 @@ if (session_status() === PHP_SESSION_NONE) { } require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; @@ -171,28 +172,20 @@ try { } // Log the upload - $conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] + $conn = Database::getConnection(); + $auditLog = new AuditLogModel($conn); + $auditLog->log( + $_SESSION['user']['user_id'], + 'attachment_upload', + 'ticket_attachments', + (string)$attachmentId, + [ + 'ticket_id' => $ticketId, + 'filename' => $originalFilename, + 'size' => $file['size'], + 'mime_type' => $mimeType + ] ); - if (!$conn->connect_error) { - $auditLog = new AuditLogModel($conn); - $auditLog->log( - $_SESSION['user']['user_id'], - 'attachment_upload', - 'ticket_attachments', - (string)$attachmentId, - [ - 'ticket_id' => $ticketId, - 'filename' => $originalFilename, - 'size' => $file['size'], - 'mime_type' => $mimeType - ] - ); - $conn->close(); - } ResponseHelper::created([ 'attachment_id' => $attachmentId, diff --git a/api/user_preferences.php b/api/user_preferences.php index 791bba0..a908ddc 100644 --- a/api/user_preferences.php +++ b/api/user_preferences.php @@ -5,6 +5,7 @@ */ require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/UserPreferencesModel.php'; session_start(); @@ -30,19 +31,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL $userId = $_SESSION['user']['user_id']; -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; -} +// Use centralized database connection +$conn = Database::getConnection(); $prefsModel = new UserPreferencesModel($conn); @@ -55,7 +45,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']); } - $conn->close(); exit; } @@ -66,8 +55,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($data['key']) || !isset($data['value'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing key or value']); - $conn->close(); - exit; + exit; } $key = trim($data['key']); @@ -86,8 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!in_array($key, $validKeys)) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid preference key']); - $conn->close(); - exit; + exit; } try { @@ -103,7 +90,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to save preference']); } - $conn->close(); exit; } @@ -114,8 +100,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { if (!isset($data['key'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing key']); - $conn->close(); - exit; + exit; } try { @@ -125,7 +110,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to delete preference']); } - $conn->close(); exit; } diff --git a/controllers/TicketController.php b/controllers/TicketController.php index a06cca3..99d27f8 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -176,25 +176,32 @@ class TicketController { } // Update ticket with user tracking - $result = $this->ticketModel->updateTicket($data, $userId); + // Pass expected_updated_at for optimistic locking if provided + $expectedUpdatedAt = $data['expected_updated_at'] ?? null; + $result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt); // Log ticket update to audit log - if ($result && isset($GLOBALS['auditLog']) && $userId) { + if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) { $GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data); } // Return JSON response header('Content-Type: application/json'); - if ($result) { + if ($result['success']) { echo json_encode([ 'success' => true, 'status' => $data['status'] ]); } else { - echo json_encode([ + $response = [ 'success' => false, - 'error' => 'Failed to update ticket' - ]); + 'error' => $result['error'] ?? 'Failed to update ticket' + ]; + if (!empty($result['conflict'])) { + $response['conflict'] = true; + $response['current_updated_at'] = $result['current_updated_at'] ?? null; + } + echo json_encode($response); } } else { // For direct access, redirect to view diff --git a/cron/cleanup_ratelimit.php b/cron/cleanup_ratelimit.php new file mode 100644 index 0000000..cb346a9 --- /dev/null +++ b/cron/cleanup_ratelimit.php @@ -0,0 +1,97 @@ +#!/usr/bin/env php +isDot() || !$file->isFile()) { + continue; + } + + // Skip lock file and non-json files + $filename = $file->getFilename(); + if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) { + continue; + } + + $scanned++; + + // Check file age + $fileAge = $now - $file->getMTime(); + if ($fileAge > $maxAge) { + $filepath = $file->getPathname(); + if (@unlink($filepath)) { + $deleted++; + } else { + $errors++; + } + } + } +} catch (Exception $e) { + echo "Error during cleanup: " . $e->getMessage() . "\n"; + @unlink($lockFile); + exit(1); +} + +// Release lock +@unlink($lockFile); + +// Output results +echo "Rate limit cleanup completed:\n"; +echo " - Scanned: {$scanned} files\n"; +echo " - Deleted: {$deleted} expired files\n"; +if ($errors > 0) { + echo " - Errors: {$errors} files could not be deleted\n"; +} + +exit($errors > 0 ? 1 : 0); diff --git a/cron/create_recurring_tickets.php b/cron/create_recurring_tickets.php index 0901a42..056bfa5 100644 --- a/cron/create_recurring_tickets.php +++ b/cron/create_recurring_tickets.php @@ -74,7 +74,7 @@ try { // Assign to user if specified if ($recurring['assigned_to']) { - $ticketModel->updateTicket($ticketId, ['assigned_to' => $recurring['assigned_to']]); + $ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']); } // Log to audit diff --git a/middleware/RateLimitMiddleware.php b/middleware/RateLimitMiddleware.php index a896838..2f3bf65 100644 --- a/middleware/RateLimitMiddleware.php +++ b/middleware/RateLimitMiddleware.php @@ -96,17 +96,58 @@ class RateLimitMiddleware { /** * Clean up old rate limit files (call periodically) + * + * Uses DirectoryIterator instead of glob() for better memory efficiency. + * A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup. */ public static function cleanupOldFiles(): void { $dir = self::getRateLimitDir(); - $files = glob($dir . '/*.json'); + $lockFile = $dir . '/.cleanup.lock'; $now = time(); $maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows + $maxLockAge = 60; // Release stale locks after 60 seconds - foreach ($files as $file) { - if ($now - filemtime($file) > $maxAge) { - @unlink($file); + // Check for existing lock to prevent concurrent cleanups + if (file_exists($lockFile)) { + $lockAge = $now - filemtime($lockFile); + if ($lockAge < $maxLockAge) { + return; // Cleanup already in progress } + @unlink($lockFile); // Stale lock + } + + // Try to acquire lock + if (!@touch($lockFile)) { + return; + } + + try { + $iterator = new DirectoryIterator($dir); + $deleted = 0; + $maxDeletes = 50; // Limit deletions per request to avoid blocking + + foreach ($iterator as $file) { + if ($deleted >= $maxDeletes) { + break; // Let cron handle the rest + } + + if ($file->isDot() || !$file->isFile()) { + continue; + } + + $filename = $file->getFilename(); + if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) { + continue; + } + + if ($now - $file->getMTime() > $maxAge) { + if (@unlink($file->getPathname())) { + $deleted++; + } + } + } + } finally { + @unlink($lockFile); } } @@ -163,10 +204,12 @@ class RateLimitMiddleware { * Apply rate limiting and send error response if exceeded * * @param string $type 'default' or 'api' + * @param bool $addHeaders Whether to add rate limit headers to response */ - public static function apply(string $type = 'default'): void { - // Periodically clean up old rate limit files (1% chance per request) - if (mt_rand(1, 100) === 1) { + public static function apply(string $type = 'default', bool $addHeaders = true): void { + // Periodically clean up old rate limit files (2% chance per request) + // Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup + if (mt_rand(1, 50) === 1) { self::cleanupOldFiles(); } @@ -174,6 +217,9 @@ class RateLimitMiddleware { http_response_code(429); header('Content-Type: application/json'); header('Retry-After: ' . self::WINDOW_SECONDS); + if ($addHeaders) { + self::addHeaders($type); + } echo json_encode([ 'success' => false, 'error' => 'Rate limit exceeded. Please try again later.', @@ -181,6 +227,11 @@ class RateLimitMiddleware { ]); exit; } + + // Add rate limit headers to successful responses + if ($addHeaders) { + self::addHeaders($type); + } } /** diff --git a/migrations/001_add_indexes.sql b/migrations/001_add_indexes.sql new file mode 100644 index 0000000..5820ef5 --- /dev/null +++ b/migrations/001_add_indexes.sql @@ -0,0 +1,48 @@ +-- Migration: Add Performance Indexes +-- Run this migration to improve query performance on common operations + +-- Single-column indexes for filtering +-- These support the most common WHERE clauses in getAllTickets() + +-- Status filtering (very common - used in almost every query) +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); + +-- Category and type filtering +CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category); +CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type); + +-- Priority filtering +CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority); + +-- Date-based filtering and sorting +CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at); +CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at); + +-- User filtering +CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by); +CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to); + +-- Visibility filtering (used in every authenticated query) +CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility); + +-- Composite indexes for common query patterns +-- These are more efficient than single indexes for combined filters + +-- Status + created_at (common sorting with status filter) +CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at); + +-- Assigned_to + status (for "my open tickets" queries) +CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status); + +-- Visibility + status (visibility filtering with status) +CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status); + +-- ticket_comments table +-- Optimize comment retrieval by ticket +CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at); + +-- Audit log indexes (if audit_log table exists) +-- Optimize audit log queries +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at); diff --git a/migrations/migrate.php b/migrations/migrate.php new file mode 100644 index 0000000..a725242 --- /dev/null +++ b/migrations/migrate.php @@ -0,0 +1,168 @@ +#!/usr/bin/env php +getMessage() . "\n"; + exit(1); +} + +// Create migrations tracking table if it doesn't exist +$createTable = "CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_filename (filename) +)"; + +if (!$conn->query($createTable)) { + echo "Error: Could not create migrations table: " . $conn->error . "\n"; + exit(1); +} + +// Get list of completed migrations +$completed = []; +$result = $conn->query("SELECT filename FROM migrations ORDER BY id"); +while ($row = $result->fetch_assoc()) { + $completed[] = $row['filename']; +} + +// Get list of migration files +$migrationsDir = __DIR__; +$files = glob($migrationsDir . '/*.sql'); +sort($files); + +if (empty($files)) { + echo "No migration files found.\n"; + exit(0); +} + +if ($statusOnly) { + echo "Migration Status:\n"; + echo str_repeat('-', 60) . "\n"; + foreach ($files as $file) { + $filename = basename($file); + $status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]'; + echo sprintf(" %s %s\n", $status, $filename); + } + exit(0); +} + +// Find pending migrations +$pending = []; +foreach ($files as $file) { + $filename = basename($file); + if (!in_array($filename, $completed)) { + $pending[] = $file; + } +} + +if (empty($pending)) { + echo "All migrations are up to date.\n"; + exit(0); +} + +echo sprintf("Found %d pending migration(s):\n", count($pending)); +foreach ($pending as $file) { + echo " - " . basename($file) . "\n"; +} +echo "\n"; + +if ($dryRun) { + echo "[DRY RUN] No changes made.\n"; + exit(0); +} + +// Run pending migrations +$success = 0; +$failed = 0; + +foreach ($pending as $file) { + $filename = basename($file); + echo "Running: $filename... "; + + $sql = file_get_contents($file); + if ($sql === false) { + echo "FAILED (could not read file)\n"; + $failed++; + continue; + } + + // Execute migration - handle multiple statements + $conn->begin_transaction(); + + try { + // Split by semicolon but respect statements properly + // Note: This doesn't handle semicolons in strings, but our migrations are simple + $statements = array_filter( + array_map('trim', explode(';', $sql)), + function($stmt) { + // Remove comments and check if there's actual SQL + $cleaned = preg_replace('/--.*$/m', '', $stmt); + return !empty(trim($cleaned)); + } + ); + + foreach ($statements as $statement) { + if (!$conn->query($statement)) { + // Some "errors" are acceptable (like "index already exists") + $error = $conn->error; + if (strpos($error, 'Duplicate key name') !== false || + strpos($error, 'already exists') !== false) { + // Index already exists, that's fine + continue; + } + throw new Exception($error); + } + } + + // Record the migration + $stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)"); + $stmt->bind_param('s', $filename); + if (!$stmt->execute()) { + throw new Exception("Could not record migration: " . $conn->error); + } + + $conn->commit(); + echo "OK\n"; + $success++; + + } catch (Exception $e) { + $conn->rollback(); + echo "FAILED (" . $e->getMessage() . ")\n"; + $failed++; + } +} + +echo "\n"; +echo "=== Migration Complete ===\n"; +echo sprintf(" Success: %d\n", $success); +echo sprintf(" Failed: %d\n", $failed); + +exit($failed > 0 ? 1 : 0); diff --git a/models/BulkOperationsModel.php b/models/BulkOperationsModel.php index ece90e7..c2e5821 100644 --- a/models/BulkOperationsModel.php +++ b/models/BulkOperationsModel.php @@ -83,7 +83,7 @@ class BulkOperationsModel { // Get current ticket from pre-loaded batch $currentTicket = $ticketsById[$ticketId] ?? null; if ($currentTicket) { - $success = $ticketModel->updateTicket([ + $updateResult = $ticketModel->updateTicket([ 'ticket_id' => $ticketId, 'title' => $currentTicket['title'], 'description' => $currentTicket['description'], @@ -92,6 +92,7 @@ class BulkOperationsModel { 'status' => 'Closed', 'priority' => $currentTicket['priority'] ], $operation['performed_by']); + $success = $updateResult['success']; if ($success) { $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, @@ -114,7 +115,7 @@ class BulkOperationsModel { if (isset($parameters['priority'])) { $currentTicket = $ticketsById[$ticketId] ?? null; if ($currentTicket) { - $success = $ticketModel->updateTicket([ + $updateResult = $ticketModel->updateTicket([ 'ticket_id' => $ticketId, 'title' => $currentTicket['title'], 'description' => $currentTicket['description'], @@ -123,6 +124,7 @@ class BulkOperationsModel { 'status' => $currentTicket['status'], 'priority' => $parameters['priority'] ], $operation['performed_by']); + $success = $updateResult['success']; if ($success) { $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, @@ -136,7 +138,7 @@ class BulkOperationsModel { if (isset($parameters['status'])) { $currentTicket = $ticketsById[$ticketId] ?? null; if ($currentTicket) { - $success = $ticketModel->updateTicket([ + $updateResult = $ticketModel->updateTicket([ 'ticket_id' => $ticketId, 'title' => $currentTicket['title'], 'description' => $currentTicket['description'], @@ -145,6 +147,7 @@ class BulkOperationsModel { 'status' => $parameters['status'], 'priority' => $currentTicket['priority'] ], $operation['performed_by']); + $success = $updateResult['success']; if ($success) { $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, diff --git a/models/StatsModel.php b/models/StatsModel.php index 1689da6..f70c619 100644 --- a/models/StatsModel.php +++ b/models/StatsModel.php @@ -2,20 +2,29 @@ /** * StatsModel - Dashboard statistics and metrics * - * Provides various ticket statistics for dashboard widgets + * Provides various ticket statistics for dashboard widgets. + * Uses caching to reduce database load for frequently accessed stats. */ -class StatsModel { - private $conn; +require_once dirname(__DIR__) . '/helpers/CacheHelper.php'; - public function __construct($conn) { +class StatsModel { + private mysqli $conn; + + /** Cache TTL for dashboard stats in seconds */ + private const STATS_CACHE_TTL = 60; + + /** Cache prefix for stats */ + private const CACHE_PREFIX = 'stats'; + + public function __construct(mysqli $conn) { $this->conn = $conn; } /** * Get count of open tickets */ - public function getOpenTicketCount() { + public function getOpenTicketCount(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -25,7 +34,7 @@ class StatsModel { /** * Get count of closed tickets */ - public function getClosedTicketCount() { + public function getClosedTicketCount(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -35,7 +44,7 @@ class StatsModel { /** * Get tickets grouped by priority */ - public function getTicketsByPriority() { + public function getTicketsByPriority(): array { $sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority"; $result = $this->conn->query($sql); $data = []; @@ -48,7 +57,7 @@ class StatsModel { /** * Get tickets grouped by status */ - public function getTicketsByStatus() { + public function getTicketsByStatus(): array { $sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')"; $result = $this->conn->query($sql); $data = []; @@ -61,7 +70,7 @@ class StatsModel { /** * Get tickets grouped by category */ - public function getTicketsByCategory() { + public function getTicketsByCategory(): array { $sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC"; $result = $this->conn->query($sql); $data = []; @@ -74,7 +83,7 @@ class StatsModel { /** * Get average resolution time in hours */ - public function getAverageResolutionTime() { + public function getAverageResolutionTime(): float { $sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours FROM tickets WHERE status = 'Closed' @@ -89,7 +98,7 @@ class StatsModel { /** * Get count of tickets created today */ - public function getTicketsCreatedToday() { + public function getTicketsCreatedToday(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -99,7 +108,7 @@ class StatsModel { /** * Get count of tickets created this week */ - public function getTicketsCreatedThisWeek() { + public function getTicketsCreatedThisWeek(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -109,7 +118,7 @@ class StatsModel { /** * Get count of tickets closed today */ - public function getTicketsClosedToday() { + public function getTicketsClosedToday(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -119,7 +128,7 @@ class StatsModel { /** * Get tickets by assignee (top 5) */ - public function getTicketsByAssignee($limit = 5) { + public function getTicketsByAssignee(int $limit = 5): array { $sql = "SELECT u.display_name, u.username, @@ -146,7 +155,7 @@ class StatsModel { /** * Get unassigned ticket count */ - public function getUnassignedTicketCount() { + public function getUnassignedTicketCount(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -156,7 +165,7 @@ class StatsModel { /** * Get critical (P1) ticket count */ - public function getCriticalTicketCount() { + public function getCriticalTicketCount(): int { $sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); @@ -165,8 +174,35 @@ class StatsModel { /** * Get all stats as a single array + * + * Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds. + * + * @param bool $forceRefresh Force a cache refresh + * @return array All dashboard statistics */ - public function getAllStats() { + public function getAllStats(bool $forceRefresh = false): array { + $cacheKey = 'dashboard_all'; + + if ($forceRefresh) { + CacheHelper::delete(self::CACHE_PREFIX, $cacheKey); + } + + return CacheHelper::remember( + self::CACHE_PREFIX, + $cacheKey, + function() { + return $this->fetchAllStats(); + }, + self::STATS_CACHE_TTL + ); + } + + /** + * Fetch all stats from database (uncached) + * + * @return array All dashboard statistics + */ + private function fetchAllStats(): array { return [ 'open_tickets' => $this->getOpenTicketCount(), 'closed_tickets' => $this->getClosedTicketCount(), @@ -182,5 +218,13 @@ class StatsModel { 'by_assignee' => $this->getTicketsByAssignee() ]; } + + /** + * Invalidate cached stats + * + * Call this method when ticket data changes to ensure fresh stats. + */ + public function invalidateCache(): void { + CacheHelper::delete(self::CACHE_PREFIX, null); + } } -?> diff --git a/models/TicketModel.php b/models/TicketModel.php index b7a382e..49d3d05 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -222,39 +222,99 @@ class TicketModel { ]; } - public function updateTicket(array $ticketData, ?int $updatedBy = null): bool { - $sql = "UPDATE tickets SET - title = ?, - priority = ?, - status = ?, - description = ?, - category = ?, - type = ?, - updated_by = ?, - updated_at = NOW() - WHERE ticket_id = ?"; + /** + * Update a ticket with optional optimistic locking + * + * @param array $ticketData Ticket data including ticket_id + * @param int|null $updatedBy User ID performing the update + * @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp + * @return array ['success' => bool, 'error' => string|null, 'conflict' => bool] + */ + public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array { + // Build query with optional optimistic locking + if ($expectedUpdatedAt !== null) { + // Optimistic locking enabled - check that updated_at hasn't changed + $sql = "UPDATE tickets SET + title = ?, + priority = ?, + status = ?, + description = ?, + category = ?, + type = ?, + updated_by = ?, + updated_at = NOW() + WHERE ticket_id = ? AND updated_at = ?"; + } else { + // No optimistic locking + $sql = "UPDATE tickets SET + title = ?, + priority = ?, + status = ?, + description = ?, + category = ?, + type = ?, + updated_by = ?, + updated_at = NOW() + WHERE ticket_id = ?"; + } $stmt = $this->conn->prepare($sql); if (!$stmt) { - return false; + return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false]; } - $stmt->bind_param( - "sissssii", - $ticketData['title'], - $ticketData['priority'], - $ticketData['status'], - $ticketData['description'], - $ticketData['category'], - $ticketData['type'], - $updatedBy, - $ticketData['ticket_id'] - ); + if ($expectedUpdatedAt !== null) { + $stmt->bind_param( + "sissssiis", + $ticketData['title'], + $ticketData['priority'], + $ticketData['status'], + $ticketData['description'], + $ticketData['category'], + $ticketData['type'], + $updatedBy, + $ticketData['ticket_id'], + $expectedUpdatedAt + ); + } else { + $stmt->bind_param( + "sissssii", + $ticketData['title'], + $ticketData['priority'], + $ticketData['status'], + $ticketData['description'], + $ticketData['category'], + $ticketData['type'], + $updatedBy, + $ticketData['ticket_id'] + ); + } $result = $stmt->execute(); + $affectedRows = $stmt->affected_rows; $stmt->close(); - return $result; + if (!$result) { + return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false]; + } + + // Check for optimistic locking conflict + if ($expectedUpdatedAt !== null && $affectedRows === 0) { + // Either ticket doesn't exist or was modified by someone else + $ticket = $this->getTicketById($ticketData['ticket_id']); + if ($ticket) { + return [ + 'success' => false, + 'error' => 'This ticket was modified by another user. Please refresh and try again.', + 'conflict' => true, + 'current_updated_at' => $ticket['updated_at'] + ]; + } else { + return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false]; + } + } + + return ['success' => true, 'error' => null, 'conflict' => false]; } public function createTicket(array $ticketData, ?int $createdBy = null): array {