feat: duplicate link action, watcher migration, fulltext search migration

- CreateTicketView: "Link as duplicate" button on each duplicate result;
  stores chosen ticket ID in hidden field, auto-creates duplicates dependency
  after ticket is saved (TicketController)
- migrations/004: ticket_watchers table (ticket_id, user_id primary key)
- migrations/005: FULLTEXT index on tickets(title, description) for fast
  relevance search replacing LIKE scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:54:00 -04:00
parent c8181e8076
commit 0acf5e84c3
4 changed files with 46 additions and 1 deletions
+11
View File
@@ -125,6 +125,17 @@ class TicketController {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
} }
// Auto-link as duplicate if requested from create form
$linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0;
if ($linkDupOf > 0) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("iii", $result['ticket_id'], $linkDupOf, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket // Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual'); NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
+10
View File
@@ -0,0 +1,10 @@
-- Migration: Add ticket watchers table
-- Allows users to subscribe to ticket updates and receive Matrix notifications.
CREATE TABLE IF NOT EXISTS ticket_watchers (
ticket_id INT NOT NULL,
user_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ticket_id, user_id),
INDEX idx_watcher_user (user_id)
);
+6
View File
@@ -0,0 +1,6 @@
-- Migration: Add FULLTEXT index on ticket title and description
-- Replaces LIKE '%term%' with MATCH ... AGAINST for dramatically better search
-- performance and relevance ranking at scale.
-- MyISAM not needed — InnoDB supports FULLTEXT since MySQL 5.6.
ALTER TABLE tickets ADD FULLTEXT INDEX ft_title_description (title, description);
+19 -1
View File
@@ -37,6 +37,7 @@ include __DIR__ . '/layout_header.php';
<input type="hidden" name="csrf_token" <input type="hidden" name="csrf_token"
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>"> value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
<?php if (isset($error)): ?> <?php if (isset($error)): ?>
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert"> <div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
@@ -269,6 +270,7 @@ include __DIR__ . '/layout_header.php';
ul.className = 'duplicate-list lt-text-sm'; ul.className = 'duplicate-list lt-text-sm';
data.duplicates.forEach(function (dup) { data.duplicates.forEach(function (dup) {
var li = document.createElement('li'); var li = document.createElement('li');
li.className = 'lt-flex lt-flex-align-center lt-flex-gap-sm lt-mb-xs';
var a = document.createElement('a'); var a = document.createElement('a');
a.href = '/ticket/' + encodeURIComponent(dup.ticket_id); a.href = '/ticket/' + encodeURIComponent(dup.ticket_id);
a.target = '_blank'; a.target = '_blank';
@@ -277,14 +279,30 @@ include __DIR__ . '/layout_header.php';
var badge = document.createElement('span'); var badge = document.createElement('span');
badge.className = 'lt-text-muted'; badge.className = 'lt-text-muted';
badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')'; badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')';
var linkBtn = document.createElement('button');
linkBtn.type = 'button';
linkBtn.className = 'lt-btn lt-btn-ghost lt-btn-xs';
linkBtn.dataset.dupId = dup.ticket_id;
linkBtn.textContent = 'Link as duplicate';
linkBtn.title = 'After creating, this ticket will be linked as a duplicate of #' + dup.ticket_id;
linkBtn.addEventListener('click', function () {
var chosen = this.dataset.dupId;
document.getElementById('linkDuplicateOf').value = chosen;
// Update all buttons to show current selection
ul.querySelectorAll('[data-dup-id]').forEach(function (b) {
b.textContent = b.dataset.dupId === chosen ? '\u2713 Will link' : 'Link as duplicate';
b.classList.toggle('lt-btn-primary', b.dataset.dupId === chosen);
});
});
li.appendChild(a); li.appendChild(a);
li.appendChild(dash); li.appendChild(dash);
li.appendChild(badge); li.appendChild(badge);
li.appendChild(linkBtn);
ul.appendChild(li); ul.appendChild(li);
}); });
var hint = document.createElement('p'); var hint = document.createElement('p');
hint.className = 'lt-text-xs lt-text-muted lt-mt-sm'; hint.className = 'lt-text-xs lt-text-muted lt-mt-sm';
hint.textContent = 'Check these before creating a new ticket.'; hint.textContent = 'Check these before creating. Use "Link as duplicate" to auto-link after create.';
list.innerHTML = ''; list.innerHTML = '';
list.appendChild(ul); list.appendChild(ul);
list.appendChild(hint); list.appendChild(hint);