Files
tinker_tickets/api/notifications.php
T
jaredandClaude Opus 4.8 2b8d593ab0
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Failing after 42s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Fix issues found in multi-agent code review
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>
2026-06-30 09:14:38 -04:00

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,
]);