Compare commits
3 Commits
dcbe6fb383
...
ac05b212b2
| Author | SHA1 | Date | |
|---|---|---|---|
| ac05b212b2 | |||
| df6c4de196 | |||
| 2ccf4f2261 |
+52
-15
@@ -65,27 +65,57 @@ $assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
// Query 2: Comments on tickets I own or watch (events from other users)
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
||||||
$commentSql = "SELECT DISTINCT
|
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
|
||||||
|
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
|
||||||
|
|
||||||
|
// Step A: ticket IDs the current user owns or watches
|
||||||
|
$myTicketIds = [];
|
||||||
|
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
|
||||||
|
$stmt = $conn->prepare($myTicketsSql);
|
||||||
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$mtResult = $stmt->get_result();
|
||||||
|
while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; }
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
||||||
|
$stmt = $conn->prepare($watchedSql);
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$wResult = $stmt->get_result();
|
||||||
|
while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; }
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step B: fetch recent comment audit events not by the current user
|
||||||
|
$commentSql = "SELECT
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
INNER JOIN tickets t ON t.ticket_id = CAST(al.entity_id AS UNSIGNED)
|
WHERE al.action_type = 'create'
|
||||||
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
|
AND al.entity_type = 'comment'
|
||||||
WHERE al.action_type = 'comment'
|
|
||||||
AND al.entity_type = 'ticket'
|
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND (t.assigned_to = ? OR t.created_by = ? OR tw.user_id IS NOT NULL)
|
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 15";
|
LIMIT 50";
|
||||||
|
|
||||||
$stmt = $conn->prepare($commentSql);
|
$stmt = $conn->prepare($commentSql);
|
||||||
$stmt->bind_param('iiii', $userId, $userId, $userId, $userId);
|
$stmt->bind_param('i', $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step C: filter to only comments on tickets the current user owns/watches
|
||||||
|
$commentRows = [];
|
||||||
|
foreach ($rawCommentRows as $rawRow) {
|
||||||
|
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
|
||||||
|
$tid = (int)($d['ticket_id'] ?? 0);
|
||||||
|
if ($tid > 0 && isset($myTicketIds[$tid])) {
|
||||||
|
$commentRows[] = $rawRow;
|
||||||
|
if (count($commentRows) >= 15) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Query 3: Status changes on watched tickets (from other users)
|
// Query 3: Status changes on watched tickets (from other users)
|
||||||
$statusSql = "SELECT DISTINCT
|
$statusSql = "SELECT DISTINCT
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
@@ -97,7 +127,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND al.details LIKE '%\"field\":\"status\"%'
|
AND al.details LIKE '%"status":%'
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 10";
|
LIMIT 10";
|
||||||
|
|
||||||
@@ -123,16 +153,23 @@ $all = array_slice($all, 0, 30);
|
|||||||
$notifications = [];
|
$notifications = [];
|
||||||
foreach ($all as $row) {
|
foreach ($all as $row) {
|
||||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
$ticketId = (int)$row['entity_id'];
|
// Comment rows: entity_id is the comment_id; real ticket_id is in details
|
||||||
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
|
? 'comment'
|
||||||
|
: $row['action_type'];
|
||||||
|
$ticketId = ($actionType === 'comment')
|
||||||
|
? (int)($details['ticket_id'] ?? 0)
|
||||||
|
: (int)$row['entity_id'];
|
||||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
// Build human-readable title
|
// Build human-readable title
|
||||||
$title = match($row['action_type']) {
|
$title = match($actionType) {
|
||||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
'update' => (function() use ($row, $details, $ticketId) {
|
'update' => (function() use ($row, $details, $ticketId) {
|
||||||
$from = $details['old_value'] ?? '?';
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
$to = $details['new_value'] ?? '?';
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||||
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||||
})(),
|
})(),
|
||||||
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
@@ -149,7 +186,7 @@ foreach ($all as $row) {
|
|||||||
'title' => $title,
|
'title' => $title,
|
||||||
'created_at' => $row['created_at'],
|
'created_at' => $row['created_at'],
|
||||||
'is_read' => $isRead,
|
'is_read' => $isRead,
|
||||||
'action' => $row['action_type'],
|
'action' => $actionType,
|
||||||
'url' => "/ticket/{$ticketId}",
|
'url' => "/ticket/{$ticketId}",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2341,6 +2341,7 @@ select option:checked {
|
|||||||
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
|
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
.lt-hidden { display: none !important; }
|
.lt-hidden { display: none !important; }
|
||||||
|
.is-hidden { display: none !important; }
|
||||||
|
|
||||||
/* Skip navigation link — visible only on focus */
|
/* Skip navigation link — visible only on focus */
|
||||||
.lt-skip-link {
|
.lt-skip-link {
|
||||||
|
|||||||
@@ -203,8 +203,7 @@ body.edit-mode .editable-metadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Visibility groups toggle ────────────────────────────────── */
|
/* ── Visibility groups toggle ────────────────────────────────── */
|
||||||
.ticket-visibility-groups.is-hidden,
|
.ticket-visibility-groups.is-hidden { display: none !important; }
|
||||||
.is-hidden { display: none !important; }
|
|
||||||
|
|
||||||
/* ── Page header utility ─────────────────────────────────────── */
|
/* ── Page header utility ─────────────────────────────────────── */
|
||||||
.lt-page-header {
|
.lt-page-header {
|
||||||
|
|||||||
@@ -198,6 +198,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
case 'open-settings-modal':
|
case 'open-settings-modal':
|
||||||
if (typeof openSettingsModal === 'function') openSettingsModal();
|
if (typeof openSettingsModal === 'function') openSettingsModal();
|
||||||
break;
|
break;
|
||||||
|
case 'close-settings':
|
||||||
|
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||||
|
break;
|
||||||
|
case 'save-settings':
|
||||||
|
if (typeof saveSettings === 'function') saveSettings();
|
||||||
|
break;
|
||||||
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
|
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
|
||||||
case 'manual-refresh':
|
case 'manual-refresh':
|
||||||
if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
|
if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
|
||||||
|
|||||||
+1
-1
@@ -1181,7 +1181,7 @@ function highlightMentions(text) {
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initMentionAutocomplete();
|
initMentionAutocomplete();
|
||||||
|
|
||||||
// Highlight existing mentions in comments
|
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
|
||||||
document.querySelectorAll('.comment-text').forEach(el => {
|
document.querySelectorAll('.comment-text').forEach(el => {
|
||||||
if (!el.hasAttribute('data-markdown')) {
|
if (!el.hasAttribute('data-markdown')) {
|
||||||
el.innerHTML = highlightMentions(el.innerHTML);
|
el.innerHTML = highlightMentions(el.innerHTML);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
|
|||||||
// Content Security Policy - restricts where resources can be loaded from
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
|
||||||
|
|
||||||
// Prevent clickjacking by disallowing framing
|
// Prevent clickjacking by disallowing framing
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||||
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
||||||
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
||||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
|
||||||
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<th scope="col" class="<?= $sortClass ?>"
|
<th scope="col" class="<?= $sortClass ?>"
|
||||||
@@ -1150,9 +1150,12 @@ document.addEventListener('change', function (e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advanced search form submit
|
// Advanced search form submit — use wrapper so performAdvancedSearch is resolved at event time
|
||||||
|
// (advanced-search.js loads later via pageScripts in layout_footer.php)
|
||||||
var advForm = document.getElementById('advancedSearchForm');
|
var advForm = document.getElementById('advancedSearchForm');
|
||||||
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
if (advForm) advForm.addEventListener('submit', function(e) {
|
||||||
|
if (typeof performAdvancedSearch === 'function') performAdvancedSearch(e);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Flatpickr date pickers on advanced search date fields ────────
|
// ── Flatpickr date pickers on advanced search date fields ────────
|
||||||
(function initFlatpickr() {
|
(function initFlatpickr() {
|
||||||
|
|||||||
Reference in New Issue
Block a user