2026-07-15 18:40:52 -04:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
|
|
|
|
|
*
|
|
|
|
|
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
|
2026-09-12 01:17:53 -04:00
|
|
|
* By default, a key only sees public-visibility tickets — Confidential and
|
|
|
|
|
* Internal tickets are excluded, the same as they would be for a regular user
|
|
|
|
|
* with no special access. An admin can mark a specific key see_all_visibility
|
|
|
|
|
* (API Key Management) when it genuinely needs the full queue.
|
2026-07-15 18:40:52 -04:00
|
|
|
*
|
|
|
|
|
* GET ?ticket_id=NNN -> {success, ticket, comments}
|
|
|
|
|
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
|
|
|
|
|
* &page=&limit=
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
|
|
2026-09-11 12:17:15 -04:00
|
|
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|
|
|
|
ErrorHandler::init();
|
2026-07-15 18:40:52 -04:00
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
2026-09-12 01:17:53 -04:00
|
|
|
// 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' => ''];
|
|
|
|
|
|
2026-07-15 18:40:52 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 01:17:53 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 18:40:52 -04:00
|
|
|
// 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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 01:17:53 -04:00
|
|
|
// $visibilityUser is null (skip filtering, full queue) only for a key marked
|
|
|
|
|
// see_all_visibility; otherwise it restricts to public tickets (see above).
|
2026-07-15 18:40:52 -04:00
|
|
|
$result = $ticketModel->getAllTickets(
|
|
|
|
|
$page,
|
|
|
|
|
$limit,
|
|
|
|
|
$status,
|
|
|
|
|
'ticket_id',
|
|
|
|
|
'desc',
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
$search,
|
|
|
|
|
$filters,
|
2026-09-12 01:17:53 -04:00
|
|
|
$visibilityUser
|
2026-07-15 18:40:52 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
echo json_encode([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'tickets' => $result['tickets'],
|
|
|
|
|
'page' => $result['current_page'],
|
|
|
|
|
'total' => $result['total'],
|
|
|
|
|
'pages' => $result['pages'],
|
|
|
|
|
]);
|
|
|
|
|
exit;
|