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';
+