Verified, high-confidence fixes from a project-wide review: - markdown.js: escape " and ' in the HTML-escape step. User-controlled image/link URLs and alt text were interpolated into "..." attributes without quote escaping, allowing attribute breakout and injected event handlers (stored XSS, only mitigated by CSP). Flagged independently by two reviewers. - cron/create_recurring_tickets.php & cron/cleanup_ratelimit.php: a mangled crontab example inside the docblock contained */ which closed the comment early, causing a fatal parse error — both cron jobs never ran. Rewrote the docblocks without a literal */. - update_ticket.php: validate visibility BEFORE the core DB write so an invalid payload can't leave the ticket updated while the request reports failure (which also skipped the audit delta and stats cache invalidation). - watch_ticket.php: GET watcher_count was capped at 6 (count of a LIMIT 6 list); use an unbounded COUNT(*) so it matches the POST path. - notifications.php: "assigned to me" LIKE pattern lacked a trailing delimiter, so user 12 also matched 120/123/etc.; anchor with }. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
8.0 KiB
PHP
217 lines
8.0 KiB
PHP
<?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.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";
|
|
|
|
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
|
|
// match 120/123/etc. The assign detail is logged as {"assigned_to":<int>}.
|
|
$assignLike = '%"assigned_to":' . (int)$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,
|
|
]);
|