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:
2026-03-29 22:00:32 -04:00
parent 0acf5e84c3
commit ade1a70214
7 changed files with 271 additions and 29 deletions
+47 -28
View File
@@ -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;
}
}