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.audit_id AS 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) // 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; $myTicketIds[$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; $myTicketIds[$wRow['ticket_id']] = true; } $stmt->close(); // Step B: fetch recent comment audit events not by the current user $commentSql = "SELECT al.audit_id AS 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 IN ('comment', 'create') AND al.entity_type = 'comment' AND al.user_id != ? AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY al.created_at DESC LIMIT 50"; $stmt = $conn->prepare($commentSql); $stmt->bind_param('i', $userId); $stmt->execute(); $rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $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) ?? []; $tidRaw = $d['ticket_id'] ?? 0; $tid = (int)$tidRaw; if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) { $commentRows[] = $rawRow; if (count($commentRows) >= 15) { break; } } } // Query 3: Status changes on watched tickets (from other users) $statusSql = "SELECT DISTINCT al.audit_id AS 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 '%\"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) ?? []; // 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') ? ($details['ticket_id'] ?? 0) : $row['entity_id']; $isRead = $lastSeen && $row['created_at'] <= $lastSeen; // Build human-readable title $title = match ($actionType) { 'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you", 'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}", 'update' => (function () use ($row, $details, $ticketId) { // logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}} $from = $details['status']['from'] ?? ($details['old_value'] ?? '?'); $to = $details['status']['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' => $actionType, 'url' => "/ticket/{$ticketId}", ]; } $unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read'])); apiRespond([ 'success' => true, 'notifications' => $notifications, 'unread_count' => $unreadCount, 'last_seen' => $lastSeen, ]);