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
+45
View File
@@ -132,6 +132,8 @@ include __DIR__ . '/layout_header.php';
</option>
<?php endforeach ?>
</select>
<button type="button" id="watchButton" class="lt-btn lt-btn-ghost lt-btn-sm"
title="Watch this ticket to receive Matrix notifications on updates">WATCH</button>
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
<a id="exportFullBtn"
@@ -768,6 +770,49 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Watch / Unwatch button
var watchBtn = document.getElementById('watchButton');
if (watchBtn) {
var _watching = false;
// Fetch initial state
lt.api.get('/api/watch_ticket.php?ticket_id=' + window.ticketData.ticket_id)
.then(function (d) {
if (d.success) {
_watching = d.watching;
watchBtn.textContent = _watching ? 'UNWATCH' : 'WATCH';
watchBtn.title = _watching
? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.';
if (_watching) watchBtn.classList.add('lt-btn-active');
}
})
.catch(function () {});
watchBtn.addEventListener('click', function () {
var action = _watching ? 'unwatch' : 'watch';
watchBtn.disabled = true;
lt.api.post('/api/watch_ticket.php', { ticket_id: window.ticketData.ticket_id, action: action })
.then(function (d) {
if (d.success) {
_watching = d.watching;
watchBtn.textContent = _watching ? 'UNWATCH' : 'WATCH';
watchBtn.title = _watching
? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.';
watchBtn.classList.toggle('lt-btn-active', _watching);
lt.toast.success(_watching ? 'Watching ticket' : 'Stopped watching ticket');
} else {
lt.toast.error('Failed: ' + (d.error || 'Unknown error'));
}
watchBtn.disabled = false;
})
.catch(function () {
lt.toast.error('Failed to update watch status');
watchBtn.disabled = false;
});
});
}
// Add comment button
var addCommentBtn = document.getElementById('addCommentBtn');
if (addCommentBtn) {