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:
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user