2026-04-04 17:21:21 -04:00
|
|
|
<?php
|
2026-04-13 20:56:10 -04:00
|
|
|
|
2026-04-04 17:21:21 -04:00
|
|
|
/**
|
|
|
|
|
* Notifications API
|
|
|
|
|
*
|
|
|
|
|
* GET → returns recent notifications for the current user (last 7 days, max 30)
|
|
|
|
|
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
|
|
|
|
|
*
|
|
|
|
|
* Notifications are derived from audit_log:
|
|
|
|
|
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
|
|
|
|
|
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
|
|
|
|
|
* - Status changes on watched (via ticket_watchers)
|
|
|
|
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
|
|
|
|
*/
|
2026-04-13 20:56:10 -04:00
|
|
|
|
2026-04-04 17:21:21 -04:00
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
|
|
|
|
|
|
|
|
|
$prefsModel = new UserPreferencesModel($conn);
|
|
|
|
|
|
|
|
|
|
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
|
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
|
|
|
if (($data['action'] ?? '') === 'mark_read') {
|
|
|
|
|
$prefsModel->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
|
2026-04-05 11:32:02 -04:00
|
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
2026-04-04 17:21:21 -04:00
|
|
|
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)
|
2026-04-05 10:53:06 -04:00
|
|
|
// 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();
|
2026-04-13 20:56:10 -04:00
|
|
|
while ($mtRow = $mtResult->fetch_assoc()) {
|
|
|
|
|
$myTicketIds[(int)$mtRow['ticket_id']] = true;
|
|
|
|
|
$myTicketIds[$mtRow['ticket_id']] = true;
|
|
|
|
|
}
|
2026-04-05 10:53:06 -04:00
|
|
|
$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();
|
2026-04-13 20:56:10 -04:00
|
|
|
while ($wRow = $wResult->fetch_assoc()) {
|
|
|
|
|
$myTicketIds[(int)$wRow['ticket_id']] = true;
|
|
|
|
|
$myTicketIds[$wRow['ticket_id']] = true;
|
|
|
|
|
}
|
2026-04-05 10:53:06 -04:00
|
|
|
$stmt->close();
|
|
|
|
|
|
|
|
|
|
// Step B: fetch recent comment audit events not by the current user
|
|
|
|
|
$commentSql = "SELECT
|
2026-04-05 11:32:02 -04:00
|
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
2026-04-04 17:21:21 -04:00
|
|
|
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
|
2026-04-11 13:45:04 -04:00
|
|
|
WHERE al.action_type IN ('comment', 'create')
|
2026-04-05 10:47:39 -04:00
|
|
|
AND al.entity_type = 'comment'
|
2026-04-04 17:21:21 -04:00
|
|
|
AND al.user_id != ?
|
|
|
|
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
|
|
|
|
ORDER BY al.created_at DESC
|
2026-04-05 10:53:06 -04:00
|
|
|
LIMIT 50";
|
2026-04-04 17:21:21 -04:00
|
|
|
|
|
|
|
|
$stmt = $conn->prepare($commentSql);
|
2026-04-05 10:53:06 -04:00
|
|
|
$stmt->bind_param('i', $userId);
|
2026-04-04 17:21:21 -04:00
|
|
|
$stmt->execute();
|
2026-04-05 10:53:06 -04:00
|
|
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
2026-04-04 17:21:21 -04:00
|
|
|
$stmt->close();
|
|
|
|
|
|
2026-04-05 10:53:06 -04:00
|
|
|
// Step C: filter to only comments on tickets the current user owns/watches
|
|
|
|
|
$commentRows = [];
|
|
|
|
|
foreach ($rawCommentRows as $rawRow) {
|
|
|
|
|
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
|
2026-04-11 12:43:18 -04:00
|
|
|
$tidRaw = $d['ticket_id'] ?? 0;
|
|
|
|
|
$tid = (int)$tidRaw;
|
|
|
|
|
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
2026-04-05 10:53:06 -04:00
|
|
|
$commentRows[] = $rawRow;
|
2026-04-13 20:56:10 -04:00
|
|
|
if (count($commentRows) >= 15) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-05 10:53:06 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 17:21:21 -04:00
|
|
|
// Query 3: Status changes on watched tickets (from other users)
|
|
|
|
|
$statusSql = "SELECT DISTINCT
|
2026-04-05 11:32:02 -04:00
|
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
2026-04-04 17:21:21 -04:00
|
|
|
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)
|
2026-04-05 11:25:26 -04:00
|
|
|
AND al.details LIKE '%\"status\":%'
|
2026-04-04 17:21:21 -04:00
|
|
|
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'];
|
2026-04-13 20:56:10 -04:00
|
|
|
if (isset($seen[$id])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-04 17:21:21 -04:00
|
|
|
$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) ?? [];
|
2026-04-05 10:47:39 -04:00
|
|
|
// 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')
|
2026-04-11 12:43:18 -04:00
|
|
|
? ($details['ticket_id'] ?? 0)
|
|
|
|
|
: $row['entity_id'];
|
2026-04-04 17:21:21 -04:00
|
|
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
|
|
|
|
|
|
|
|
|
// Build human-readable title
|
2026-04-13 20:56:10 -04:00
|
|
|
$title = match ($actionType) {
|
2026-04-04 17:21:21 -04:00
|
|
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
|
|
|
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
2026-04-13 20:56:10 -04:00
|
|
|
'update' => (function () use ($row, $details, $ticketId) {
|
2026-04-05 10:47:39 -04:00
|
|
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
|
|
|
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
|
|
|
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
2026-04-04 17:21:21 -04:00
|
|
|
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,
|
2026-04-05 10:47:39 -04:00
|
|
|
'action' => $actionType,
|
2026-04-04 17:21:21 -04:00
|
|
|
'url' => "/ticket/{$ticketId}",
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
|
|
|
|
|
|
|
|
|
|
apiRespond([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'notifications' => $notifications,
|
|
|
|
|
'unread_count' => $unreadCount,
|
|
|
|
|
'last_seen' => $lastSeen,
|
|
|
|
|
]);
|