204 lines
6.7 KiB
PHP
204 lines
6.7 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
/**
|
||
|
|
* ticket_status_api.php — Bearer-key endpoint to change a ticket's status.
|
||
|
|
*
|
||
|
|
* POST only. Requires 'read_write' scope.
|
||
|
|
*
|
||
|
|
* Body (JSON): {
|
||
|
|
* "ticket_id": "NNN" (required),
|
||
|
|
* "status": "..." (required target status),
|
||
|
|
* "comment": "..." (optional; REQUIRED when the transition
|
||
|
|
* requires_comment),
|
||
|
|
* "markdown_enabled": bool (optional, applies to the comment)
|
||
|
|
* }
|
||
|
|
* Response: {success:true, ticket_id, status}
|
||
|
|
*
|
||
|
|
* Mirrors api/update_ticket.php: workflow validation, requires_comment
|
||
|
|
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
|
||
|
|
* status-change notification, and StatsModel cache invalidation. When a comment
|
||
|
|
* is supplied it is posted first (per-key label) so "close with reason" is one call.
|
||
|
|
*/
|
||
|
|
|
||
|
|
header('Content-Type: application/json');
|
||
|
|
|
||
|
|
error_reporting(E_ALL);
|
||
|
|
ini_set('display_errors', 0);
|
||
|
|
|
||
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||
|
|
RateLimitMiddleware::apply('api');
|
||
|
|
|
||
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
||
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||
|
|
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
|
||
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||
|
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||
|
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||
|
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||
|
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||
|
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||
|
|
|
||
|
|
try {
|
||
|
|
$conn = Database::getConnection();
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
error_log('ticket_status_api: DB connection failed: ' . $e->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;
|