getMessage()); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Internal server error']); exit; } $apiKeyAuth = new ApiKeyAuth($conn); try { $apiKeyAuth->authenticate(); } catch (Exception $e) { // ApiKeyAuth already sent the 401 response. exit; } // Changing status is a write — reject 'read' keys with 403 before any mutation. $apiKeyAuth->requireScope('read_write'); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']); exit; } $context = $apiKeyAuth->getKeyContext(); $keyName = $context['key_name'] ?? 'API'; $createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null; $rawInput = file_get_contents('php://input'); $data = json_decode($rawInput, true); if (!is_array($data)) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid JSON body']); exit; } $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; if ($ticketId === '') { http_response_code(400); echo json_encode(['success' => false, 'error' => 'ticket_id is required']); exit; } $newStatus = isset($data['status']) ? trim((string)$data['status']) : ''; if ($newStatus === '') { http_response_code(400); echo json_encode(['success' => false, 'error' => 'status is required']); exit; } $comment = isset($data['comment']) ? trim((string)$data['comment']) : ''; // Validate the ticket exists. $ticketModel = new TicketModel($conn); $ticket = $ticketModel->getTicketById($ticketId); if (!$ticket) { http_response_code(404); echo json_encode(['success' => false, 'error' => 'Ticket not found']); exit; } $currentStatus = (string)$ticket['status']; // Validate the transition (API key is never admin). $workflowModel = new WorkflowModel($conn); if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) { http_response_code(400); echo json_encode([ 'success' => false, 'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus, ]); exit; } // Enforce requires_comment transitions server-side. if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') { http_response_code(400); echo json_encode([ 'success' => false, 'error' => 'A comment is required for this status change', 'requires_comment' => true, ]); exit; } // Post the comment first (per-key label) so a close-with-reason is one call. if ($comment !== '') { $commentModel = new CommentModel($conn); $commentResult = $commentModel->addComment($ticketId, [ 'user_name' => $keyName, 'comment_text' => $comment, 'markdown_enabled' => !empty($data['markdown_enabled']), ], $createdBy); if (empty($commentResult['success'])) { error_log('ticket_status_api: addComment failed for ticket ' . $ticketId . ': ' . ($commentResult['error'] ?? 'unknown')); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to add comment']); exit; } } // Apply the status change. updateTicket sets updated_by/updated_at and handles // closed_at (set on close, cleared on reopen) via its own SQL. $updateData = [ 'ticket_id' => $ticketId, 'title' => $ticket['title'], 'description' => $ticket['description'], 'category' => $ticket['category'], 'type' => $ticket['type'], 'status' => $newStatus, 'priority' => (int)$ticket['priority'], ]; $updateResult = $ticketModel->updateTicket($updateData, $createdBy); if (empty($updateResult['success'])) { error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId . ': ' . ($updateResult['error'] ?? 'unknown')); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']); exit; } // Notify, audit, and refresh stats only when the status actually changed. if ($currentStatus !== $newStatus) { NotificationHelper::sendStatusChangeNotification( $ticketId, $currentStatus, $newStatus, (string)$ticket['title'], $keyName ); NotificationHelper::notifyWatchers( $conn, $ticketId, (string)$ticket['title'], 'status_changed', ['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName], $createdBy, $ticket['visibility'] ?? 'public' ); // Audit trail (action 'update' / entity 'ticket' are both whitelisted). $auditLog = new AuditLogModel($conn); $auditLog->log($createdBy, 'update', 'ticket', $ticketId, [ 'status' => ['from' => $currentStatus, 'to' => $newStatus], 'key_name' => $keyName, 'via_api' => true, ]); // Status change is a ticket-state change — refresh dashboard stats. (new StatsModel($conn))->invalidateCache(); } echo json_encode([ 'success' => true, 'ticket_id' => $ticketId, 'status' => $newStatus, ]); exit;