feat: ticket watchers, fulltext search, single-query pagination, watcher notifications
Ticket watchers: - api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle) - index.php: route for /api/watch_ticket.php - TicketView: WATCH/UNWATCH button with live state fetch and toggle - NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves Matrix IDs via Synapse, fires notification to watchers + global list - add_comment.php, update_ticket.php: call notifyWatchers on comment and status-change events respectively Fulltext search: - TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema - getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback when not yet applied — zero-downtime rollout Single-query pagination: - getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window function — one round trip to DB per page load instead of two Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+47
-28
@@ -77,12 +77,20 @@ class TicketModel {
|
||||
$paramTypes .= str_repeat('s', count($types));
|
||||
}
|
||||
|
||||
// Search Functionality
|
||||
// Search Functionality — use FULLTEXT when available, fall back to LIKE
|
||||
if ($search && !empty($search)) {
|
||||
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)";
|
||||
$searchTerm = "%$search%";
|
||||
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
||||
$paramTypes .= 'sssss';
|
||||
if ($this->hasFulltextIndex()) {
|
||||
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
||||
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||
$searchTerm = "%$search%";
|
||||
$params = array_merge($params, [$search . '*', $searchTerm, $searchTerm, $searchTerm]);
|
||||
$paramTypes .= 'ssss';
|
||||
} else {
|
||||
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||
$searchTerm = "%$search%";
|
||||
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
||||
$paramTypes .= 'sssss';
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced search filters
|
||||
@@ -166,53 +174,44 @@ class TicketModel {
|
||||
// Validate sort direction
|
||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count for pagination
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
|
||||
$countStmt = $this->conn->prepare($countSql);
|
||||
|
||||
if (!empty($params)) {
|
||||
$countStmt->bind_param($paramTypes, ...$params);
|
||||
}
|
||||
|
||||
$countStmt->execute();
|
||||
$totalResult = $countStmt->get_result();
|
||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
||||
|
||||
// Get tickets with pagination and creator info
|
||||
// Single query: use COUNT(*) OVER() window function to get total + page in one pass
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
u_assigned.display_name as assigned_display_name,
|
||||
COUNT(*) OVER() as _total_count
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
$whereClause
|
||||
ORDER BY $sortExpression $sortDirection
|
||||
LIMIT ? OFFSET ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Add limit and offset parameters
|
||||
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$paramTypes .= 'ii';
|
||||
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!empty($params)) {
|
||||
$stmt->bind_param($paramTypes, ...$params);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$tickets = [];
|
||||
|
||||
$tickets = [];
|
||||
$totalTickets = 0;
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$totalTickets = (int)$row['_total_count'];
|
||||
unset($row['_total_count']);
|
||||
$tickets[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'tickets' => $tickets,
|
||||
'total' => $totalTickets,
|
||||
'pages' => ceil($totalTickets / $limit),
|
||||
'pages' => $totalTickets > 0 ? ceil($totalTickets / $limit) : 0,
|
||||
'current_page' => $page
|
||||
];
|
||||
}
|
||||
@@ -701,4 +700,24 @@ class TicketModel {
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
||||
* Result is cached for the process lifetime (static).
|
||||
*/
|
||||
private function hasFulltextIndex(): bool {
|
||||
static $result = null;
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
$r = $this->conn->query(
|
||||
"SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'tickets'
|
||||
AND index_type = 'FULLTEXT'
|
||||
AND index_name = 'ft_title_description'"
|
||||
);
|
||||
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user