{success, ticket, comments} * GET ?status=&priority=&host= -> {success, tickets, page, total, pages} * &page=&limit= */ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/helpers/ErrorHandler.php'; ErrorHandler::init(); // 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'); // Keys are public-ticket-only by default (#70) — a key must be explicitly // marked see_all_visibility to bypass Confidential/Internal restrictions. // Reuse TicketModel's existing per-user visibility plumbing with a synthetic // "no special access" user rather than a separate SQL path, so this stays in // lockstep with however visibility rules evolve for real users. user_id is // -1, not 0: canUserAccessTicket() does a PHP-level (int) cast for the // confidential-ticket check, and (int)null === 0, so an unassigned // confidential ticket's assigned_to would otherwise false-positive-match a // synthetic user_id of 0. No real user_id is ever <= 0, so -1 can't collide. $keyContext = $apiKeyAuth->getKeyContext(); $visibilityUser = !empty($keyContext['see_all_visibility']) ? null : ['user_id' => -1, 'is_admin' => false, 'groups' => '']; 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; } // A public-only key gets a plain 404 for a non-public ticket — same as a // regular user hitting a ticket they can't see — rather than a 403 that // would confirm the ticket exists. if ($visibilityUser !== null && !$ticketModel->canUserAccessTicket($ticket, $visibilityUser)) { 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']); } // $visibilityUser is null (skip filtering, full queue) only for a key marked // see_all_visibility; otherwise it restricts to public tickets (see above). $result = $ticketModel->getAllTickets( $page, $limit, $status, 'ticket_id', 'desc', null, null, $search, $filters, $visibilityUser ); echo json_encode([ 'success' => true, 'tickets' => $result['tickets'], 'page' => $result['current_page'], 'total' => $result['total'], 'pages' => $result['pages'], ]); exit;