Files
tinker_tickets/api/notifications.php
T
jaredandClaude Opus 4.8 99c840fce0
Security / PHP Security (semgrep) (push) Failing after 2m44s
Lint / Deploy (push) Successful in 8s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Fix logic bugs found in third multi-agent review
Medium:
- create_ticket_api.php: environment tags were parsed with explode('][') which
  left brackets on the first/last tag so the whitelist never matched, dropping
  the env tag from the dedup hash — a [production] and [staging] issue with
  otherwise-identical components could collide onto one ticket. Use a
  bracket-aware regex.
- CommentModel::getThreadedCommentsPaged only fetched DIRECT children of root
  comments, so when pagination is active, nested replies at depth 2-3 vanished
  from the thread. Expand replies level-by-level (bounded to depth 3).
- StatsModel::getTicketsByAssignee ignored the visibility filter the rest of the
  stats apply, so a non-admin's "by assignee" widget counted (leaked) confidential
  tickets. Thread the same filter through.
- watch_ticket.php GET path returned watch state / watcher names / count for any
  ticket with no access check (the POST path checks it) — added canUserAccessTicket.
- dashboard.js kanban: every card rendered as P4 because the [class*="lt-p"]
  selector never matched the lt-badge-p1 class and the fallback didn't strip "P".
  Extract the digit directly.

Low:
- audit_log.php CSV: "Log ID" column was always blank ($log['log_id'] vs the real
  audit_id column). Use audit_id.
- check_duplicates.php: the graceful-degradation try/catch only covered the throw
  path; guard the false-return (non-exception mysqli) path too.
- notifications.php: owner-who-is-also-@mentioned got two notifications for one
  comment; drop the duplicate comment row when a mention covers the same comment.
- dashboard.js hover preview rendered "PP1" (doubled prefix); strip the leading P.
- markdown.js: code/inline-code restore used string replace, so $&, $$, $`, $' in
  user code were treated as replacement patterns; use a function replacer. Also
  removed an unused loop var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:21:35 -04:00

260 lines
9.8 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 ? OR 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. Single assigns log {"assigned_to":5} (closing brace) while
// bulk assigns log {"assigned_to":5,"bulk_operation_id":N} (comma) — match both.
$assignId = (int)$userId;
$assignEnd = '%"assigned_to":' . $assignId . '}%';
$assignMid = '%"assigned_to":' . $assignId . ',%';
$stmt = $conn->prepare($assignSql);
$stmt->bind_param('iss', $userId, $assignEnd, $assignMid);
$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();
// Query 4: @mentions of me (logged by add_comment.php as
// action_type='mention', entity_type='user', entity_id=<mentioned user_id>).
$mentionSql = "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 = 'mention'
AND al.entity_type = 'user'
AND al.entity_id = ?
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY al.created_at DESC
LIMIT 15";
$mentionEntityId = (string)$userId;
$stmt = $conn->prepare($mentionSql);
$stmt->bind_param('si', $mentionEntityId, $userId);
$stmt->execute();
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// If the user owns/watches a ticket AND was @mentioned in the same comment, the
// comment query and the mention query both produce a row for it. Prefer the more
// specific mention and drop the duplicate comment notification for that comment.
$mentionCommentIds = [];
foreach ($mentionRows as $mr) {
$md = json_decode($mr['details'] ?? '{}', true) ?? [];
if (!empty($md['comment_id'])) {
$mentionCommentIds[(int)$md['comment_id']] = true;
}
}
if (!empty($mentionCommentIds)) {
$commentRows = array_filter(
$commentRows,
fn($cr) => !isset($mentionCommentIds[(int)($cr['entity_id'] ?? 0)])
);
}
// Merge, deduplicate by log_id, sort by created_at desc
$all = [];
$seen = [];
foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) 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' || $actionType === 'mention')
? ($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}",
'mention' => "{$row['actor_name']} mentioned you 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,
]);