diff --git a/api/notifications.php b/api/notifications.php new file mode 100644 index 0000000..7c88ef9 --- /dev/null +++ b/api/notifications.php @@ -0,0 +1,164 @@ +setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s')); + apiRespond(['success' => true]); + } else { + http_response_code(400); + apiRespond(['success' => false, 'error' => 'Unknown action']); + } + exit; +} + +// ── GET: fetch notifications ────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + http_response_code(405); + apiRespond(['success' => false, 'error' => 'Method not allowed']); + exit; +} + +// Get last_seen timestamp (when user last marked all read) +$prefs = $prefsModel->getUserPreferences($userId); +$lastSeen = $prefs['notif_last_seen'] ?? null; + +// Username for @mention detection +$myUsername = $currentUser['username'] ?? ''; + +// Query 1: Tickets assigned to me (events from other users) +$assignSql = "SELECT + 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 + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + WHERE al.action_type = 'assign' + AND al.entity_type = 'ticket' + AND al.user_id != ? + AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + AND al.details LIKE ? + ORDER BY al.created_at DESC + LIMIT 15"; + +$assignLike = '%"assigned_to":' . $userId . '%'; +$stmt = $conn->prepare($assignSql); +$stmt->bind_param('is', $userId, $assignLike); +$stmt->execute(); +$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); +$stmt->close(); + +// Query 2: Comments on tickets I own or watch (events from other users) +$commentSql = "SELECT DISTINCT + 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 + FROM audit_log al + 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) + LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ? + WHERE al.action_type = 'comment' + AND al.entity_type = 'ticket' + AND al.user_id != ? + 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 + LIMIT 15"; + +$stmt = $conn->prepare($commentSql); +$stmt->bind_param('iiii', $userId, $userId, $userId, $userId); +$stmt->execute(); +$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); +$stmt->close(); + +// Query 3: Status changes on watched tickets (from other users) +$statusSql = "SELECT DISTINCT + 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 + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ? + WHERE al.action_type = 'update' + AND al.entity_type = 'ticket' + AND al.user_id != ? + AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + AND al.details LIKE '%\"field\":\"status\"%' + ORDER BY al.created_at DESC + LIMIT 10"; + +$stmt = $conn->prepare($statusSql); +$stmt->bind_param('ii', $userId, $userId); +$stmt->execute(); +$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); +$stmt->close(); + +// Merge, deduplicate by log_id, sort by created_at desc +$all = []; +$seen = []; +foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) { + $id = (int)$row['log_id']; + if (isset($seen[$id])) continue; + $seen[$id] = true; + $all[] = $row; +} +usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); +$all = array_slice($all, 0, 30); + +// Format for response +$notifications = []; +foreach ($all as $row) { + $details = json_decode($row['details'] ?? '{}', true) ?? []; + $ticketId = (int)$row['entity_id']; + $isRead = $lastSeen && $row['created_at'] <= $lastSeen; + + // Build human-readable title + $title = match($row['action_type']) { + 'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you", + 'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}", + 'update' => (function() use ($row, $details, $ticketId) { + $from = $details['old_value'] ?? '?'; + $to = $details['new_value'] ?? '?'; + return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}"; + })(), + default => "{$row['actor_name']} updated ticket #{$ticketId}", + }; + + $ticketTitle = $details['title'] ?? null; + if ($ticketTitle) { + $title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : ''); + } + + $notifications[] = [ + 'log_id' => (int)$row['log_id'], + 'ticket_id' => $ticketId, + 'title' => $title, + 'created_at' => $row['created_at'], + 'is_read' => $isRead, + 'action' => $row['action_type'], + 'url' => "/ticket/{$ticketId}", + ]; +} + +$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read'])); + +apiRespond([ + 'success' => true, + 'notifications' => $notifications, + 'unread_count' => $unreadCount, + 'last_seen' => $lastSeen, +]); diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 68af2aa..cab45e6 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -1151,7 +1151,13 @@ function populateKanbanCards() { card.className = 'lt-kanban-card lt-kanban-card--p' + pNum; card.setAttribute('role', 'button'); card.setAttribute('tabindex', '0'); - card.onclick = () => window.location.href = '/ticket/' + encodeURIComponent(ticketId); + card.dataset.ticketId = ticketId; + card.dataset.status = status; + card.addEventListener('click', (e) => { + // Don't navigate if drag just ended (drag adds/removes is-dragging briefly) + if (card.dataset.dragged) { delete card.dataset.dragged; return; } + window.location.href = '/ticket/' + encodeURIComponent(ticketId); + }); card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); }; card.innerHTML = '