diff --git a/api/audit_log.php b/api/audit_log.php
index b4fb78c..dc0248d 100644
--- a/api/audit_log.php
+++ b/api/audit_log.php
@@ -70,7 +70,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
fputcsv($output, [
- $log['log_id'],
+ $log['audit_id'] ?? ($log['log_id'] ?? ''),
$log['created_at'],
$log['display_name'] ?? $log['username'] ?? 'N/A',
$log['action_type'],
diff --git a/api/check_duplicates.php b/api/check_duplicates.php
index 1fb36d2..1cb85f8 100644
--- a/api/check_duplicates.php
+++ b/api/check_duplicates.php
@@ -64,6 +64,11 @@ try {
}
$stmt->execute();
$result = $stmt->get_result();
+ if ($result === false) {
+ // Non-exception mysqli mode: execute/get_result return false instead of
+ // throwing. Treat as a query failure so we don't fatal on $result below.
+ throw new RuntimeException('query failed: ' . $conn->error);
+ }
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
ResponseHelper::success(['duplicates' => []]);
diff --git a/api/notifications.php b/api/notifications.php
index c46a4e7..c3d6449 100644
--- a/api/notifications.php
+++ b/api/notifications.php
@@ -175,6 +175,23 @@ $stmt->execute();
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
+// If the user owns/watches a ticket AND was @mentioned in the same comment, the
+// comment query and the mention query both produce a row for it. Prefer the more
+// specific mention and drop the duplicate comment notification for that comment.
+$mentionCommentIds = [];
+foreach ($mentionRows as $mr) {
+ $md = json_decode($mr['details'] ?? '{}', true) ?? [];
+ if (!empty($md['comment_id'])) {
+ $mentionCommentIds[(int)$md['comment_id']] = true;
+ }
+}
+if (!empty($mentionCommentIds)) {
+ $commentRows = array_filter(
+ $commentRows,
+ fn($cr) => !isset($mentionCommentIds[(int)($cr['entity_id'] ?? 0)])
+ );
+}
+
// Merge, deduplicate by log_id, sort by created_at desc
$all = [];
$seen = [];
diff --git a/api/watch_ticket.php b/api/watch_ticket.php
index 6889e91..375dc69 100644
--- a/api/watch_ticket.php
+++ b/api/watch_ticket.php
@@ -78,6 +78,17 @@ if ($ticketId <= 0) {
exit;
}
+// Enforce ticket visibility before returning watch state / watcher names, so a
+// restricted ticket's watcher list and count aren't disclosed (the POST path
+// already checks this).
+$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;
+}
+
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js
index 2c542d7..03628a2 100644
--- a/assets/js/dashboard.js
+++ b/assets/js/dashboard.js
@@ -1142,8 +1142,10 @@ function populateKanbanCards() {
if (cells.length < 6) return;
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
- const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
- const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
+ // The priority cell renders a "P1".."P5" badge; extract just the digit.
+ // (The old [class*="lt-p"] selector never matched the lt-badge-p1 class, so
+ // every card fell back to P4 regardless of real priority.)
+ const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/[^0-9]/g, '') || '4';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
@@ -1315,7 +1317,9 @@ function showTicketPreview(event) {
const offset = isAdmin ? 1 : 0;
const ticketId = link.textContent.trim();
- const priority = cells[1 + offset]?.textContent.trim() || '';
+ // Cell text is already "P1".."P5"; strip the leading P so the template's
+ // `P${priority}` doesn't render "PP1".
+ const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/^P/i, '');
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const type = cells[4 + offset]?.textContent.trim() || '';
diff --git a/assets/js/markdown.js b/assets/js/markdown.js
index dc59958..77c83f7 100644
--- a/assets/js/markdown.js
+++ b/assets/js/markdown.js
@@ -142,12 +142,14 @@ function parseMarkdown(markdown) {
html = html.replace(/ \n/g, '
');
html = html.replace(/\n\n/g, '
'); - // Restore code blocks and inline code + // Restore code blocks and inline code. Use a function replacer so '$' + // sequences in user code (e.g. $&, $$, $`, $') are inserted literally rather + // than interpreted as String.replace replacement patterns. codeBlocks.forEach((block, i) => { - html = html.replace('%%CODEBLOCK' + i + '%%', block); + html = html.replace('%%CODEBLOCK' + i + '%%', () => block); }); inlineCodes.forEach((code, i) => { - html = html.replace('%%INLINECODE' + i + '%%', code); + html = html.replace('%%INLINECODE' + i + '%%', () => code); }); // Restore footnote reference placeholders @@ -164,7 +166,7 @@ function parseMarkdown(markdown) { // Append footnote definitions block if (footnoteOrder.length) { html += '