Add performance, security, and reliability improvements

- Consolidate all 20 API files to use centralized Database helper
- Add optimistic locking to ticket updates to prevent concurrent conflicts
- Add caching to StatsModel (60s TTL) for dashboard performance
- Add health check endpoint (api/health.php) for monitoring
- Improve rate limit cleanup with cron script and efficient DirectoryIterator
- Enable rate limit response headers (X-RateLimit-*)
- Add audit logging for workflow transitions
- Log Discord webhook failures instead of silencing
- Fix visibility check on export_tickets.php
- Add database migration system with performance indexes
- Fix cron recurring tickets to use assignTicket method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 14:39:13 -05:00
parent c3f7593f3c
commit 7575d6a277
31 changed files with 825 additions and 398 deletions

View File

@@ -27,6 +27,7 @@ try {
require_once $configPath; require_once $configPath;
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication via session // Check authentication via session
session_start(); session_start();
@@ -49,17 +50,8 @@ try {
$currentUser = $_SESSION['user']; $currentUser = $_SESSION['user'];
$userId = $currentUser['user_id']; $userId = $currentUser['user_id'];
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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);
}
// Get POST data // Get POST data
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);

View File

@@ -5,6 +5,7 @@ RateLimitMiddleware::apply('api');
session_start(); session_start();
require_once dirname(__DIR__) . '/config/config.php'; 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/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__) . '/models/UserModel.php';
@@ -40,18 +41,8 @@ if (!$ticketId) {
exit; exit;
} }
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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;
}
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
@@ -69,7 +60,6 @@ if ($assignedTo === null || $assignedTo === '') {
$targetUser = $userModel->getUserById($assignedTo); $targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) { if (!$targetUser) {
echo json_encode(['success' => false, 'error' => 'Invalid user ID']); echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
$conn->close();
exit; exit;
} }
@@ -80,6 +70,4 @@ if ($assignedTo === null || $assignedTo === '') {
} }
} }
$conn->close();
echo json_encode(['success' => $success]); echo json_encode(['success' => $success]);

View File

@@ -6,6 +6,7 @@
*/ */
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
session_start(); session_start();
@@ -26,19 +27,8 @@ if (!$isAdmin) {
exit; exit;
} }
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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;
}
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
@@ -90,7 +80,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
} }
fclose($output); fclose($output);
$conn->close();
exit; exit;
} }

View File

@@ -5,6 +5,7 @@ RateLimitMiddleware::apply('api');
session_start(); session_start();
require_once dirname(__DIR__) . '/config/config.php'; 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/BulkOperationsModel.php';
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';
@@ -55,21 +56,8 @@ foreach ($ticketIds as $ticketId) {
} }
} }
// Create database connection (needed for visibility check) // Use centralized database connection
require_once dirname(__DIR__) . '/models/TicketModel.php'; $conn = Database::getConnection();
// 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;
}
$bulkOpsModel = new BulkOperationsModel($conn); $bulkOpsModel = new BulkOperationsModel($conn);
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
@@ -92,7 +80,6 @@ foreach ($ticketIds as $ticketId) {
} }
if (empty($accessibleTicketIds)) { if (empty($accessibleTicketIds)) {
$conn->close();
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']); echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
exit; exit;
} }
@@ -104,7 +91,6 @@ $ticketIds = $accessibleTicketIds;
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters); $operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
if (!$operationId) { if (!$operationId) {
$conn->close();
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']); echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
exit; exit;
} }

View File

@@ -11,6 +11,7 @@ RateLimitMiddleware::apply('api');
session_start(); session_start();
require_once dirname(__DIR__) . '/config/config.php'; 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__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -32,17 +33,8 @@ if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]); ResponseHelper::success(['duplicates' => []]);
} }
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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');
}
// Search for similar titles // Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching // Use both LIKE for substring matching and SOUNDEX for phonetic matching
@@ -112,6 +104,4 @@ usort($duplicates, function($a, $b) {
// Limit to top 5 // Limit to top 5
$duplicates = array_slice($duplicates, 0, 5); $duplicates = array_slice($duplicates, 0, 5);
$conn->close();
ResponseHelper::success(['duplicates' => $duplicates]); ResponseHelper::success(['duplicates' => $duplicates]);

View File

@@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api');
try { try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php'; require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
// Check authentication // Check authentication
@@ -40,16 +41,8 @@ try {
} }
} }
$conn = new mysqli( // Use centralized database connection
$GLOBALS['config']['DB_HOST'], $conn = Database::getConnection();
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed");
}
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -103,8 +96,6 @@ try {
echo json_encode(['success' => false, 'error' => 'Method not allowed']); echo json_encode(['success' => false, 'error' => 'Method not allowed']);
} }
$conn->close();
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);

View File

@@ -19,6 +19,7 @@ if (session_status() === PHP_SESSION_NONE) {
} }
require_once dirname(__DIR__) . '/config/config.php'; 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__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
@@ -87,27 +88,19 @@ try {
} }
// Log the deletion // Log the deletion
$conn = new mysqli( $conn = Database::getConnection();
$GLOBALS['config']['DB_HOST'], $auditLog = new AuditLogModel($conn);
$GLOBALS['config']['DB_USER'], $auditLog->log(
$GLOBALS['config']['DB_PASS'], $_SESSION['user']['user_id'],
$GLOBALS['config']['DB_NAME'] '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'); ResponseHelper::success([], 'Attachment deleted successfully');

View File

@@ -16,6 +16,7 @@ ob_start();
try { try {
require_once dirname(__DIR__) . '/config/config.php'; 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/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
@@ -39,17 +40,8 @@ try {
$userId = $currentUser['user_id']; $userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false; $isAdmin = $currentUser['is_admin'] ?? false;
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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");
}
// Get data - support both POST body and query params // Get data - support both POST body and query params
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);

View File

@@ -7,6 +7,7 @@
session_start(); session_start();
require_once dirname(__DIR__) . '/config/config.php'; 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/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
@@ -42,24 +43,12 @@ try {
} }
// Verify the associated ticket exists and user has access // Verify the associated ticket exists and user has access
$conn = new mysqli( $conn = Database::getConnection();
$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;
}
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']); $ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket) { if (!$ticket) {
$conn->close();
http_response_code(404); http_response_code(404);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']); 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 // Check if user has access to this ticket based on visibility settings
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
$conn->close();
http_response_code(403); http_response_code(403);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']); echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);

View File

@@ -3,6 +3,7 @@
* Export Tickets API * Export Tickets API
* *
* Exports tickets to CSV format with optional filtering * Exports tickets to CSV format with optional filtering
* Respects ticket visibility settings
*/ */
// Disable error display in the output // Disable error display in the output
@@ -16,6 +17,7 @@ RateLimitMiddleware::apply('api');
try { try {
// Include required files // Include required files
require_once dirname(__DIR__) . '/config/config.php'; 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/TicketModel.php';
// Check authentication via session // Check authentication via session
@@ -29,17 +31,8 @@ try {
$currentUser = $_SESSION['user']; $currentUser = $_SESSION['user'];
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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");
}
// Get filter parameters // Get filter parameters
$status = isset($_GET['status']) ? $_GET['status'] : null; $status = isset($_GET['status']) ? $_GET['status'] : null;
@@ -64,11 +57,18 @@ try {
} }
// Get specific tickets by IDs // Get specific tickets by IDs
$tickets = $ticketModel->getTicketsByIds($ticketIdArray); $allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
// Convert associative array to indexed array
$tickets = array_values($tickets); // 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 { } else {
// Get all tickets with filters (no pagination for export) // 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); $result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
$tickets = $result['tickets']; $tickets = $result['tickets'];
} }

View File

@@ -12,6 +12,7 @@ ob_start();
try { try {
// Load config // Load config
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load models // Load models
require_once dirname(__DIR__) . '/models/ApiKeyModel.php'; require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
@@ -71,17 +72,8 @@ try {
$expiresInDays = null; $expiresInDays = null;
} }
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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");
}
// Generate API key // Generate API key
$apiKeyModel = new ApiKeyModel($conn); $apiKeyModel = new ApiKeyModel($conn);
@@ -101,8 +93,6 @@ try {
['key_name' => $keyName, 'expires_in_days' => $expiresInDays] ['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
); );
$conn->close();
// Clear output buffer // Clear output buffer
ob_end_clean(); ob_end_clean();

110
api/health.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
/**
* Health Check Endpoint
*
* Returns system health status for monitoring tools.
* Does not require authentication - suitable for load balancer health checks.
*
* Returns:
* - 200 OK: System is healthy
* - 503 Service Unavailable: System has issues
*/
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
$startTime = microtime(true);
$checks = [];
$healthy = true;
// Check 1: Database connectivity
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
// Quick query to verify connection is actually working
$result = $conn->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);

View File

@@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api');
try { try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php'; require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
// Check authentication // Check authentication
@@ -42,16 +43,8 @@ try {
} }
} }
$conn = new mysqli( // Use centralized database connection
$GLOBALS['config']['DB_HOST'], $conn = Database::getConnection();
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed");
}
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -130,8 +123,6 @@ try {
echo json_encode(['success' => false, 'error' => 'Method not allowed']); echo json_encode(['success' => false, 'error' => 'Method not allowed']);
} }
$conn->close();
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);

View File

@@ -12,6 +12,7 @@ RateLimitMiddleware::apply('api');
try { try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication // Check authentication
session_start(); session_start();
@@ -39,16 +40,8 @@ try {
} }
} }
$conn = new mysqli( // Use centralized database connection
$GLOBALS['config']['DB_HOST'], $conn = Database::getConnection();
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed");
}
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -145,8 +138,6 @@ try {
echo json_encode(['success' => false, 'error' => 'Method not allowed']); echo json_encode(['success' => false, 'error' => 'Method not allowed']);
} }
$conn->close();
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);

View File

@@ -13,6 +13,8 @@ RateLimitMiddleware::apply('api');
try { try {
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication // Check authentication
session_start(); session_start();
@@ -40,16 +42,12 @@ try {
} }
} }
$conn = new mysqli( // Use centralized database connection
$GLOBALS['config']['DB_HOST'], $conn = Database::getConnection();
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) { // Initialize audit log
throw new Exception("Database connection failed"); $auditLog = new AuditLogModel($conn);
} $userId = $_SESSION['user']['user_id'];
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -92,8 +90,18 @@ try {
); );
if ($stmt->execute()) { if ($stmt->execute()) {
$transitionId = $conn->insert_id;
WorkflowModel::clearCache(); // Clear workflow cache 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 { } else {
echo json_encode(['success' => false, 'error' => $stmt->error]); echo json_encode(['success' => false, 'error' => $stmt->error]);
} }
@@ -123,6 +131,14 @@ try {
$success = $stmt->execute(); $success = $stmt->execute();
if ($success) { if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache 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]); echo json_encode(['success' => $success]);
$stmt->close(); $stmt->close();
@@ -134,11 +150,25 @@ try {
exit; 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 = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
$stmt->bind_param('i', $id); $stmt->bind_param('i', $id);
$success = $stmt->execute(); $success = $stmt->execute();
if ($success) { if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache 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]); echo json_encode(['success' => $success]);
$stmt->close(); $stmt->close();
@@ -149,8 +179,6 @@ try {
echo json_encode(['success' => false, 'error' => 'Method not allowed']); echo json_encode(['success' => false, 'error' => 'Method not allowed']);
} }
$conn->close();
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]); echo json_encode(['success' => false, 'error' => $e->getMessage()]);

View File

@@ -12,6 +12,7 @@ ob_start();
try { try {
// Load config // Load config
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load models // Load models
require_once dirname(__DIR__) . '/models/ApiKeyModel.php'; require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
@@ -56,17 +57,8 @@ try {
throw new Exception("Valid key ID is required"); throw new Exception("Valid key ID is required");
} }
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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");
}
// Get key info for audit log // Get key info for audit log
$apiKeyModel = new ApiKeyModel($conn); $apiKeyModel = new ApiKeyModel($conn);
@@ -97,8 +89,6 @@ try {
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']] ['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
); );
$conn->close();
// Clear output buffer // Clear output buffer
ob_end_clean(); ob_end_clean();

View File

@@ -5,6 +5,7 @@
*/ */
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php'; require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
session_start(); session_start();
@@ -30,19 +31,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT
$userId = $_SESSION['user']['user_id']; $userId = $_SESSION['user']['user_id'];
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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;
}
$filtersModel = new SavedFiltersModel($conn); $filtersModel = new SavedFiltersModel($conn);
@@ -72,7 +62,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']); echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
} }
$conn->close();
exit; exit;
} }
@@ -83,8 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
$conn->close(); exit;
exit;
} }
$filterName = trim($data['filter_name']); $filterName = trim($data['filter_name']);
@@ -95,8 +83,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($filterName) || strlen($filterName) > 100) { if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']); echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
$conn->close(); exit;
exit;
} }
try { try {
@@ -106,7 +93,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save filter']); echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
} }
$conn->close();
exit; exit;
} }
@@ -117,8 +103,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!isset($data['filter_id'])) { if (!isset($data['filter_id'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']); echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
$conn->close(); exit;
exit;
} }
$filterId = (int)$data['filter_id']; $filterId = (int)$data['filter_id'];
@@ -132,16 +117,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
} }
$conn->close(); exit;
exit;
} }
// Handle full filter update // Handle full filter update
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
$conn->close(); exit;
exit;
} }
$filterName = trim($data['filter_name']); $filterName = trim($data['filter_name']);
@@ -155,7 +138,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update filter']); echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
} }
$conn->close();
exit; exit;
} }
@@ -166,8 +148,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['filter_id'])) { if (!isset($data['filter_id'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']); echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
$conn->close(); exit;
exit;
} }
$filterId = (int)$data['filter_id']; $filterId = (int)$data['filter_id'];
@@ -179,7 +160,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']); echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
} }
$conn->close();
exit; exit;
} }

View File

@@ -64,6 +64,7 @@ if (session_status() === PHP_SESSION_NONE) {
} }
require_once dirname(__DIR__) . '/config/config.php'; 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/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
@@ -86,17 +87,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
} }
} }
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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');
}
// Check if ticket_dependencies table exists // Check if ticket_dependencies table exists
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'"); $tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
@@ -211,6 +203,4 @@ switch ($method) {
// Log detailed error server-side // Log detailed error server-side
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
ResponseHelper::serverError('An error occurred while processing the dependency request'); ResponseHelper::serverError('An error occurred while processing the dependency request');
} };
$conn->close();

View File

@@ -16,6 +16,7 @@ ob_start();
try { try {
require_once dirname(__DIR__) . '/config/config.php'; 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/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
@@ -41,17 +42,8 @@ try {
$userId = $currentUser['user_id']; $userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false; $isAdmin = $currentUser['is_admin'] ?? false;
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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");
}
// Get POST/PUT data // Get POST/PUT data
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);

View File

@@ -14,6 +14,7 @@ try {
// Load config // Load config
$configPath = dirname(__DIR__) . '/config/config.php'; $configPath = dirname(__DIR__) . '/config/config.php';
require_once $configPath; require_once $configPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load environment variables (for Discord webhook) // Load environment variables (for Discord webhook)
$envPath = dirname(__DIR__) . '/.env'; $envPath = dirname(__DIR__) . '/.env';
@@ -141,11 +142,25 @@ try {
} }
} }
// Update ticket with user tracking // Update ticket with user tracking and optional optimistic locking
$result = $this->ticketModel->updateTicket($updateData, $this->userId); $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 // Handle visibility update if provided
if ($result && isset($data['visibility'])) { if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null; $visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed // Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) { if (is_array($visibilityGroups)) {
@@ -163,27 +178,20 @@ try {
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
} }
if ($result) { // Log ticket update to audit log
// Log ticket update to audit log if ($this->userId) {
if ($this->userId) { $this->auditLog->logTicketUpdate($this->userId, $id, $data);
$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'
];
} }
// 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) { private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
@@ -260,22 +268,18 @@ try {
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch); $curlError = curl_error($ch);
curl_close($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 // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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);
}
// Check request method // Check request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -302,9 +306,6 @@ try {
// Update ticket // Update ticket
$result = $controller->update($ticketId, $data); $result = $controller->update($ticketId, $data);
// Close database connection
$conn->close();
// Discard any output that might have been generated // Discard any output that might have been generated
ob_end_clean(); ob_end_clean();

View File

@@ -19,6 +19,7 @@ if (session_status() === PHP_SESSION_NONE) {
} }
require_once dirname(__DIR__) . '/config/config.php'; 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__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
@@ -171,28 +172,20 @@ try {
} }
// Log the upload // Log the upload
$conn = new mysqli( $conn = Database::getConnection();
$GLOBALS['config']['DB_HOST'], $auditLog = new AuditLogModel($conn);
$GLOBALS['config']['DB_USER'], $auditLog->log(
$GLOBALS['config']['DB_PASS'], $_SESSION['user']['user_id'],
$GLOBALS['config']['DB_NAME'] '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([ ResponseHelper::created([
'attachment_id' => $attachmentId, 'attachment_id' => $attachmentId,

View File

@@ -5,6 +5,7 @@
*/ */
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php'; require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
session_start(); session_start();
@@ -30,19 +31,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
$userId = $_SESSION['user']['user_id']; $userId = $_SESSION['user']['user_id'];
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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;
}
$prefsModel = new UserPreferencesModel($conn); $prefsModel = new UserPreferencesModel($conn);
@@ -55,7 +45,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']); echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
} }
$conn->close();
exit; exit;
} }
@@ -66,8 +55,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($data['key']) || !isset($data['value'])) { if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']); echo json_encode(['success' => false, 'error' => 'Missing key or value']);
$conn->close(); exit;
exit;
} }
$key = trim($data['key']); $key = trim($data['key']);
@@ -86,8 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($key, $validKeys)) { if (!in_array($key, $validKeys)) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']); echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
$conn->close(); exit;
exit;
} }
try { try {
@@ -103,7 +90,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preference']); echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
} }
$conn->close();
exit; exit;
} }
@@ -114,8 +100,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['key'])) { if (!isset($data['key'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']); echo json_encode(['success' => false, 'error' => 'Missing key']);
$conn->close(); exit;
exit;
} }
try { try {
@@ -125,7 +110,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']); echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
} }
$conn->close();
exit; exit;
} }

View File

@@ -176,25 +176,32 @@ class TicketController {
} }
// Update ticket with user tracking // 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 // 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); $GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
} }
// Return JSON response // Return JSON response
header('Content-Type: application/json'); header('Content-Type: application/json');
if ($result) { if ($result['success']) {
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'status' => $data['status'] 'status' => $data['status']
]); ]);
} else { } else {
echo json_encode([ $response = [
'success' => false, '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 { } else {
// For direct access, redirect to view // For direct access, redirect to view

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env php
<?php
/**
* Rate Limit Cleanup Cron Job
*
* Cleans up expired rate limit files from the temp directory.
* Should be run via cron every 5-10 minutes:
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
*
* This script can also be run manually for immediate cleanup.
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
// Configuration
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
$lockFile = $rateLimitDir . '/.cleanup.lock';
$maxAge = 120; // 2 minutes (2x the rate limit window)
$maxLockAge = 300; // 5 minutes - release stale locks
// Check if directory exists
if (!is_dir($rateLimitDir)) {
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
exit(0);
}
// Acquire lock to prevent concurrent cleanups
if (file_exists($lockFile)) {
$lockAge = time() - filemtime($lockFile);
if ($lockAge < $maxLockAge) {
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
exit(0);
}
// Stale lock, remove it
@unlink($lockFile);
}
// Create lock file
if (!@touch($lockFile)) {
echo "Could not create lock file\n";
exit(1);
}
$now = time();
$deleted = 0;
$scanned = 0;
$errors = 0;
try {
$iterator = new DirectoryIterator($rateLimitDir);
foreach ($iterator as $file) {
if ($file->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);

View File

@@ -74,7 +74,7 @@ try {
// Assign to user if specified // Assign to user if specified
if ($recurring['assigned_to']) { if ($recurring['assigned_to']) {
$ticketModel->updateTicket($ticketId, ['assigned_to' => $recurring['assigned_to']]); $ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
} }
// Log to audit // Log to audit

View File

@@ -96,17 +96,58 @@ class RateLimitMiddleware {
/** /**
* Clean up old rate limit files (call periodically) * 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 { public static function cleanupOldFiles(): void {
$dir = self::getRateLimitDir(); $dir = self::getRateLimitDir();
$files = glob($dir . '/*.json'); $lockFile = $dir . '/.cleanup.lock';
$now = time(); $now = time();
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows $maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
$maxLockAge = 60; // Release stale locks after 60 seconds
foreach ($files as $file) { // Check for existing lock to prevent concurrent cleanups
if ($now - filemtime($file) > $maxAge) { if (file_exists($lockFile)) {
@unlink($file); $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 * Apply rate limiting and send error response if exceeded
* *
* @param string $type 'default' or 'api' * @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 { public static function apply(string $type = 'default', bool $addHeaders = true): void {
// Periodically clean up old rate limit files (1% chance per request) // Periodically clean up old rate limit files (2% chance per request)
if (mt_rand(1, 100) === 1) { // Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
if (mt_rand(1, 50) === 1) {
self::cleanupOldFiles(); self::cleanupOldFiles();
} }
@@ -174,6 +217,9 @@ class RateLimitMiddleware {
http_response_code(429); http_response_code(429);
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Retry-After: ' . self::WINDOW_SECONDS); header('Retry-After: ' . self::WINDOW_SECONDS);
if ($addHeaders) {
self::addHeaders($type);
}
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => 'Rate limit exceeded. Please try again later.', 'error' => 'Rate limit exceeded. Please try again later.',
@@ -181,6 +227,11 @@ class RateLimitMiddleware {
]); ]);
exit; exit;
} }
// Add rate limit headers to successful responses
if ($addHeaders) {
self::addHeaders($type);
}
} }
/** /**

View File

@@ -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);

168
migrations/migrate.php Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env php
<?php
/**
* Database Migration Runner
*
* Runs SQL migration files in order. Tracks completed migrations
* to prevent re-running them.
*
* Usage:
* php migrate.php # Run all pending migrations
* php migrate.php --status # Show migration status
* php migrate.php --dry-run # Show what would be run without executing
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$dryRun = in_array('--dry-run', $argv);
$statusOnly = in_array('--status', $argv);
echo "=== Database Migration Runner ===\n\n";
try {
$conn = Database::getConnection();
} catch (Exception $e) {
echo "Error: Could not connect to database: " . $e->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);

View File

@@ -83,7 +83,7 @@ class BulkOperationsModel {
// Get current ticket from pre-loaded batch // Get current ticket from pre-loaded batch
$currentTicket = $ticketsById[$ticketId] ?? null; $currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$success = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $currentTicket['title'], 'title' => $currentTicket['title'],
'description' => $currentTicket['description'], 'description' => $currentTicket['description'],
@@ -92,6 +92,7 @@ class BulkOperationsModel {
'status' => 'Closed', 'status' => 'Closed',
'priority' => $currentTicket['priority'] 'priority' => $currentTicket['priority']
], $operation['performed_by']); ], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
@@ -114,7 +115,7 @@ class BulkOperationsModel {
if (isset($parameters['priority'])) { if (isset($parameters['priority'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; $currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$success = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $currentTicket['title'], 'title' => $currentTicket['title'],
'description' => $currentTicket['description'], 'description' => $currentTicket['description'],
@@ -123,6 +124,7 @@ class BulkOperationsModel {
'status' => $currentTicket['status'], 'status' => $currentTicket['status'],
'priority' => $parameters['priority'] 'priority' => $parameters['priority']
], $operation['performed_by']); ], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
@@ -136,7 +138,7 @@ class BulkOperationsModel {
if (isset($parameters['status'])) { if (isset($parameters['status'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; $currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$success = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $currentTicket['title'], 'title' => $currentTicket['title'],
'description' => $currentTicket['description'], 'description' => $currentTicket['description'],
@@ -145,6 +147,7 @@ class BulkOperationsModel {
'status' => $parameters['status'], 'status' => $parameters['status'],
'priority' => $currentTicket['priority'] 'priority' => $currentTicket['priority']
], $operation['performed_by']); ], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, $auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,

View File

@@ -2,20 +2,29 @@
/** /**
* StatsModel - Dashboard statistics and metrics * 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 { require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
private $conn;
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; $this->conn = $conn;
} }
/** /**
* Get count of open tickets * 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')"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -25,7 +34,7 @@ class StatsModel {
/** /**
* Get count of closed tickets * Get count of closed tickets
*/ */
public function getClosedTicketCount() { public function getClosedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -35,7 +44,7 @@ class StatsModel {
/** /**
* Get tickets grouped by priority * 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"; $sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$data = []; $data = [];
@@ -48,7 +57,7 @@ class StatsModel {
/** /**
* Get tickets grouped by status * 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')"; $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); $result = $this->conn->query($sql);
$data = []; $data = [];
@@ -61,7 +70,7 @@ class StatsModel {
/** /**
* Get tickets grouped by category * 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"; $sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$data = []; $data = [];
@@ -74,7 +83,7 @@ class StatsModel {
/** /**
* Get average resolution time in hours * 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 $sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
FROM tickets FROM tickets
WHERE status = 'Closed' WHERE status = 'Closed'
@@ -89,7 +98,7 @@ class StatsModel {
/** /**
* Get count of tickets created today * 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()"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -99,7 +108,7 @@ class StatsModel {
/** /**
* Get count of tickets created this week * 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)"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -109,7 +118,7 @@ class StatsModel {
/** /**
* Get count of tickets closed today * 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()"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -119,7 +128,7 @@ class StatsModel {
/** /**
* Get tickets by assignee (top 5) * Get tickets by assignee (top 5)
*/ */
public function getTicketsByAssignee($limit = 5) { public function getTicketsByAssignee(int $limit = 5): array {
$sql = "SELECT $sql = "SELECT
u.display_name, u.display_name,
u.username, u.username,
@@ -146,7 +155,7 @@ class StatsModel {
/** /**
* Get unassigned ticket count * 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'"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -156,7 +165,7 @@ class StatsModel {
/** /**
* Get critical (P1) ticket count * 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'"; $sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
@@ -165,8 +174,35 @@ class StatsModel {
/** /**
* Get all stats as a single array * 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 [ return [
'open_tickets' => $this->getOpenTicketCount(), 'open_tickets' => $this->getOpenTicketCount(),
'closed_tickets' => $this->getClosedTicketCount(), 'closed_tickets' => $this->getClosedTicketCount(),
@@ -182,5 +218,13 @@ class StatsModel {
'by_assignee' => $this->getTicketsByAssignee() '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);
}
} }
?>

View File

@@ -222,39 +222,99 @@ class TicketModel {
]; ];
} }
public function updateTicket(array $ticketData, ?int $updatedBy = null): bool { /**
$sql = "UPDATE tickets SET * Update a ticket with optional optimistic locking
title = ?, *
priority = ?, * @param array $ticketData Ticket data including ticket_id
status = ?, * @param int|null $updatedBy User ID performing the update
description = ?, * @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
category = ?, * @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
type = ?, */
updated_by = ?, public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
updated_at = NOW() // Build query with optional optimistic locking
WHERE ticket_id = ?"; 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); $stmt = $this->conn->prepare($sql);
if (!$stmt) { if (!$stmt) {
return false; return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
} }
$stmt->bind_param( if ($expectedUpdatedAt !== null) {
"sissssii", $stmt->bind_param(
$ticketData['title'], "sissssiis",
$ticketData['priority'], $ticketData['title'],
$ticketData['status'], $ticketData['priority'],
$ticketData['description'], $ticketData['status'],
$ticketData['category'], $ticketData['description'],
$ticketData['type'], $ticketData['category'],
$updatedBy, $ticketData['type'],
$ticketData['ticket_id'] $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(); $result = $stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close(); $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 { public function createTicket(array $ticketData, ?int $createdBy = null): array {