2026-04-04 17:21:21 -04:00
|
|
|
<?php
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
*/
|
|
|
|
|
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
|
|
|
|
|
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)
|
2026-04-05 10:47:39 -04:00
|
|
|
// Comments are logged as action_type='create', entity_type='comment', with ticket_id in details JSON.
|
2026-04-04 17:21:21 -04:00
|
|
|
$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
|
2026-04-05 10:47:39 -04:00
|
|
|
INNER JOIN tickets t ON t.ticket_id = CAST(JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) AS UNSIGNED)
|
2026-04-04 17:21:21 -04:00
|
|
|
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
|
2026-04-05 10:47:39 -04:00
|
|
|
WHERE al.action_type = 'create'
|
|
|
|
|
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)
|
|
|
|
|
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) ?? [];
|
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')
|
|
|
|
|
? (int)($details['ticket_id'] ?? 0)
|
|
|
|
|
: (int)$row['entity_id'];
|
2026-04-04 17:21:21 -04:00
|
|
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
|
|
|
|
|
|
|
|
|
// Build human-readable title
|
2026-04-05 10:47:39 -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}",
|
|
|
|
|
'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,
|
|
|
|
|
]);
|