diff --git a/api/add_comment.php b/api/add_comment.php index 5937b36..1454ac5 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -144,6 +144,13 @@ try { NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay); } + // Notify watchers of the new comment + NotificationHelper::notifyWatchers( + $conn, $ticketId, $ticketTitle, 'comment_added', + ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')], + (int)$userId + ); + // Add mentioned users to result for frontend $result['mentions'] = array_map(function($u) { return $u['username']; diff --git a/api/update_ticket.php b/api/update_ticket.php index 8d29f00..9096cdb 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -54,6 +54,7 @@ try { // Updated controller class that handles partial updates class ApiTicketController { + private $conn; private $ticketModel; private $commentModel; private $auditLog; @@ -63,6 +64,7 @@ try { private $currentUser; public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) { + $this->conn = $conn; $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); $this->auditLog = new AuditLogModel($conn); @@ -208,7 +210,7 @@ try { } } - // Notify on status change + // Notify on status change (global notify list + watchers) if ($currentTicket['status'] !== $updateData['status']) { $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null; NotificationHelper::sendStatusChangeNotification( @@ -218,6 +220,14 @@ try { $updateData['title'], $changedBy ); + NotificationHelper::notifyWatchers( + $this->conn, + $id, + $updateData['title'], + 'status_changed', + ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy], + (int)$this->userId + ); } return [ diff --git a/api/watch_ticket.php b/api/watch_ticket.php new file mode 100644 index 0000000..c5026e7 --- /dev/null +++ b/api/watch_ticket.php @@ -0,0 +1,100 @@ + false, 'error' => 'Invalid parameters']); + exit; + } + + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Ticket not found']); + exit; + } + + if ($action === 'watch') { + $stmt = $conn->prepare( + "INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)" + ); + $stmt->bind_param("ii", $ticketId, $userId); + $stmt->execute(); + $stmt->close(); + } else { + $stmt = $conn->prepare( + "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?" + ); + $stmt->bind_param("ii", $ticketId, $userId); + $stmt->execute(); + $stmt->close(); + } + + // Return updated state + $countStmt = $conn->prepare( + "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?" + ); + $countStmt->bind_param("i", $ticketId); + $countStmt->execute(); + $count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; + $countStmt->close(); + + echo json_encode([ + 'success' => true, + 'watching' => $action === 'watch', + 'watcher_count' => $count, + ]); + exit; +} + +// GET — return current watch state for this user +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); + exit; +} + +if ($ticketId <= 0) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ticket_id required']); + exit; +} + +$watchingStmt = $conn->prepare( + "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?" +); +$watchingStmt->bind_param("ii", $ticketId, $userId); +$watchingStmt->execute(); +$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt']; +$watchingStmt->close(); + +$countStmt = $conn->prepare( + "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?" +); +$countStmt->bind_param("i", $ticketId); +$countStmt->execute(); +$count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; +$countStmt->close(); + +echo json_encode([ + 'success' => true, + 'watching' => $watching, + 'watcher_count' => $count, +]); diff --git a/helpers/NotificationHelper.php b/helpers/NotificationHelper.php index 1d5e09d..a482697 100644 --- a/helpers/NotificationHelper.php +++ b/helpers/NotificationHelper.php @@ -136,6 +136,63 @@ class NotificationHelper { ]); } + /** + * Notify all watchers of a ticket about an update event. + * + * Fetches watchers from the DB, resolves their Matrix IDs via Synapse, + * and fires the appropriate event notification with them in notify_users. + * + * @param \mysqli $conn + * @param string|int $ticketId + * @param string $ticketTitle + * @param string $event One of: status_changed, comment_added, assigned + * @param array $extraData Merged into the payload (old_status/new_status, author, etc.) + * @param int|null $excludeUserId Don't notify the actor themselves + */ + public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void { + $webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null; + $domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null; + if (!$webhookUrl || !$domain) { + return; + } + + // Fetch watcher usernames + $sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?"; + $stmt = $conn->prepare($sql); + $stmt->bind_param("i", $ticketId); + $stmt->execute(); + $result = $stmt->get_result(); + $stmt->close(); + + $usernames = []; + while ($row = $result->fetch_assoc()) { + $usernames[] = $row['username']; + } + + if (empty($usernames)) { + return; + } + + // Resolve to Matrix IDs — skip users without Synapse accounts + $matrixIds = SynapseHelper::resolveUsernames($usernames); + if (empty($matrixIds)) { + return; + } + + // Remove the global notify list duplicates and build payload + $allNotify = array_unique(array_merge($matrixIds, self::notifyUsers())); + + $payload = array_merge($extraData, [ + 'event' => $event, + 'ticket_id' => $ticketId, + 'title' => $ticketTitle, + 'url' => UrlHelper::ticketUrl($ticketId), + 'notify_users' => array_values($allNotify), + ]); + + self::fire($payload); + } + /** * Ticket assigned (or reassigned) to a user. * diff --git a/index.php b/index.php index d4dba09..5fd2198 100644 --- a/index.php +++ b/index.php @@ -119,6 +119,10 @@ switch (true) { require_once 'api/get_comments.php'; break; + case $requestPath == '/api/watch_ticket.php': + require_once 'api/watch_ticket.php'; + break; + case $requestPath == '/api/assign_ticket.php': require_once 'api/assign_ticket.php'; break; diff --git a/models/TicketModel.php b/models/TicketModel.php index 35e1e8c..87bad9c 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -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; + } } \ No newline at end of file diff --git a/views/TicketView.php b/views/TicketView.php index 7408f8e..ef71eac 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -132,6 +132,8 @@ include __DIR__ . '/layout_header.php'; +