diff --git a/api/ticket_comment_api.php b/api/ticket_comment_api.php new file mode 100644 index 0000000..da9f303 --- /dev/null +++ b/api/ticket_comment_api.php @@ -0,0 +1,125 @@ +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; +} + +// Posting a comment 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; +} + +$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; +if ($commentText === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'comment_text is required']); + exit; +} + +$markdownEnabled = !empty($data['markdown_enabled']); + +// 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; +} + +// Post the comment under the key's label / owner. +$commentModel = new CommentModel($conn); +$result = $commentModel->addComment($ticketId, [ + 'user_name' => $keyName, + 'comment_text' => $commentText, + 'markdown_enabled' => $markdownEnabled, +], $createdBy); + +if (empty($result['success'])) { + error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId + . ': ' . ($result['error'] ?? 'unknown')); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to add comment']); + exit; +} + +$commentId = $result['comment_id'] ?? null; + +// Audit trail (action 'comment' / entity 'comment' are both whitelisted). +$auditLog = new AuditLogModel($conn); +$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [ + 'ticket_id' => $ticketId, + 'key_name' => $keyName, + 'via_api' => true, +]); + +echo json_encode(['success' => true, 'comment_id' => $commentId]); +exit; diff --git a/api/ticket_status_api.php b/api/ticket_status_api.php new file mode 100644 index 0000000..144ce68 --- /dev/null +++ b/api/ticket_status_api.php @@ -0,0 +1,203 @@ +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; diff --git a/api/tickets_api.php b/api/tickets_api.php new file mode 100644 index 0000000..5a04ee0 --- /dev/null +++ b/api/tickets_api.php @@ -0,0 +1,139 @@ + {success, ticket, comments} + * GET ?status=&priority=&host= -> {success, tickets, page, total, pages} + * &page=&limit= + */ + +header('Content-Type: application/json'); + +error_reporting(E_ALL); +ini_set('display_errors', 0); + +// Rate limiting (same pattern as the other Bearer API endpoints) +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'; + +try { + $conn = Database::getConnection(); +} catch (Throwable $e) { + error_log('tickets_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; +} + +// Reads only need the 'read' scope. +$apiKeyAuth->requireScope('read'); + +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']); + exit; +} + +$ticketModel = new TicketModel($conn); + +// ── READ ONE ────────────────────────────────────────────────────────────── +if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') { + $ticketId = trim((string)$_GET['ticket_id']); + + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Ticket not found']); + exit; + } + + // Flat list of comments (newest first) — same fetch the ticket view uses. + $commentModel = new CommentModel($conn); + $comments = $commentModel->getCommentsByTicketId($ticketId, false); + + echo json_encode([ + 'success' => true, + 'ticket' => $ticket, + 'comments' => $comments, + ]); + exit; +} + +// ── LIST / TRIAGE ─────────────────────────────────────────────────────────── +$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '') + ? trim((string)$_GET['status']) + : 'Open'; + +$page = isset($_GET['page']) ? (int)$_GET['page'] : 1; +if ($page < 1) { + $page = 1; +} + +$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25; +if ($limit < 1) { + $limit = 25; +} +if ($limit > 100) { + $limit = 100; // cap +} + +$filters = []; +if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') { + $priority = (int)$_GET['priority']; + if ($priority >= 1 && $priority <= 5) { + // Exact-priority match via the min/max range filter. + $filters['priority_min'] = $priority; + $filters['priority_max'] = $priority; + } +} + +// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a +// title substring match — served by getAllTickets's `search` param (title search). +$search = null; +if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') { + $search = trim((string)$_GET['host']); +} + +// user = null => getAllTickets skips visibility filtering and returns the full +// queue (this is a trusted server credential, not an end user). +$result = $ticketModel->getAllTickets( + $page, + $limit, + $status, + 'ticket_id', + 'desc', + null, + null, + $search, + $filters, + null +); + +echo json_encode([ + 'success' => true, + 'tickets' => $result['tickets'], + 'page' => $result['current_page'], + 'total' => $result['total'], + 'pages' => $result['pages'], +]); +exit;