false, 'error' => 'Invalid CSRF token']); exit; } } $currentUser = $_SESSION['user']; $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; // Updated controller class that handles partial updates class ApiTicketController { private $ticketModel; private $commentModel; private $auditLog; private $workflowModel; private $envVars; private $userId; private $isAdmin; public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) { $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); $this->auditLog = new AuditLogModel($conn); $this->workflowModel = new WorkflowModel($conn); $this->envVars = $envVars; $this->userId = $userId; $this->isAdmin = $isAdmin; } public function update($id, $data) { // First, get the current ticket data to fill in missing fields $currentTicket = $this->ticketModel->getTicketById($id); if (!$currentTicket) { return [ 'success' => false, 'error' => 'Ticket not found' ]; } // Merge current data with updates, keeping existing values for missing fields $updateData = [ 'ticket_id' => $id, 'title' => $data['title'] ?? $currentTicket['title'], 'description' => $data['description'] ?? $currentTicket['description'], 'category' => $data['category'] ?? $currentTicket['category'], 'type' => $data['type'] ?? $currentTicket['type'], 'status' => $data['status'] ?? $currentTicket['status'], 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] ]; // Validate required fields if (empty($updateData['title'])) { return [ 'success' => false, 'error' => 'Title cannot be empty' ]; } // Validate priority range if ($updateData['priority'] < 1 || $updateData['priority'] > 5) { return [ 'success' => false, 'error' => 'Priority must be between 1 and 5' ]; } // Validate status transition using workflow model if ($currentTicket['status'] !== $updateData['status']) { $allowed = $this->workflowModel->isTransitionAllowed( $currentTicket['status'], $updateData['status'], $this->isAdmin ); if (!$allowed) { return [ 'success' => false, 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] ]; } } // 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 (isset($data['visibility'])) { $visibilityGroups = $data['visibility_groups'] ?? null; // Convert array to comma-separated string if needed if (is_array($visibilityGroups)) { $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); } // Validate internal visibility requires groups if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) { return [ 'success' => false, 'error' => 'Internal visibility requires at least one group to be specified' ]; } $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); } // 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) { if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) { return; } $webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL']; // Determine what fields actually changed $changes = []; foreach ($changedFields as $field => $newValue) { if ($field === 'ticket_id') continue; // Skip ticket_id $oldValue = $oldData[$field] ?? 'N/A'; if ($oldValue != $newValue) { $changes[] = [ 'name' => ucfirst($field), 'value' => "$oldValue → $newValue", 'inline' => true ]; } } if (empty($changes)) { return; } // Create ticket URL $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 't.lotusguild.org'; $ticketUrl = "{$protocol}://{$host}/ticket/{$ticketId}"; // Determine embed color based on priority $colors = [ 1 => 0xff4d4d, // Red 2 => 0xffa726, // Orange 3 => 0x42a5f5, // Blue 4 => 0x66bb6a, // Green 5 => 0x9e9e9e // Gray ]; $color = $colors[$newData['priority']] ?? 0x3498db; $embed = [ 'title' => '🔄 Ticket Updated', 'description' => "**#{$ticketId}** - " . $newData['title'], 'color' => $color, 'fields' => array_merge($changes, [ [ 'name' => '🔗 View Ticket', 'value' => "[Click here to view]($ticketUrl)", 'inline' => false ] ]), 'footer' => [ 'text' => 'Tinker Tickets' ], 'timestamp' => date('c') ]; $payload = [ 'embeds' => [$embed] ]; // Send webhook $ch = curl_init($webhookUrl); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $webhookResult = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); // 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)); } } } // Use centralized database connection $conn = Database::getConnection(); // Check request method if ($_SERVER['REQUEST_METHOD'] !== 'POST') { throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']); } // Get POST data $input = file_get_contents('php://input'); $data = json_decode($input, true); if (!$data) { throw new Exception("Invalid JSON data received: " . $input); } if (!isset($data['ticket_id'])) { throw new Exception("Missing ticket_id parameter"); } $ticketId = (int)$data['ticket_id']; // Initialize controller $controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin); // Update ticket $result = $controller->update($ticketId, $data); // Discard any output that might have been generated ob_end_clean(); // Return response header('Content-Type: application/json'); echo json_encode($result); } catch (Exception $e) { // Discard any output that might have been generated ob_end_clean(); // Return error response header('Content-Type: application/json'); http_response_code(500); echo json_encode([ 'success' => false, 'error' => $e->getMessage() ]); } ?>