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
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>
220 lines
8.3 KiB
PHP
220 lines
8.3 KiB
PHP
<?php
|
|
|
|
/**
|
|
* StatsModel - Dashboard statistics and metrics
|
|
*
|
|
* Provides various ticket statistics for dashboard widgets.
|
|
* Uses caching to reduce database load for frequently accessed stats.
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
|
|
class StatsModel
|
|
{
|
|
private mysqli $conn;
|
|
|
|
/** Cache TTL for dashboard stats in seconds */
|
|
private const STATS_CACHE_TTL = 60;
|
|
|
|
/** Cache prefix for stats */
|
|
private const CACHE_PREFIX = 'stats';
|
|
|
|
public function __construct(mysqli $conn)
|
|
{
|
|
$this->conn = $conn;
|
|
}
|
|
|
|
/**
|
|
* Get tickets by assignee (top 5)
|
|
*/
|
|
public function getTicketsByAssignee(int $limit = 8, array $visFilter = []): array
|
|
{
|
|
// Apply the same visibility filter as the rest of the stats so a non-admin's
|
|
// assignee widget doesn't count (and thereby leak) confidential tickets.
|
|
$visSQL = $visFilter['sql'] ?? '';
|
|
$visParams = $visFilter['params'] ?? [];
|
|
$visTypes = $visFilter['types'] ?? '';
|
|
|
|
$sql = "SELECT
|
|
u.user_id,
|
|
u.display_name,
|
|
u.username,
|
|
COUNT(t.ticket_id) as open_count
|
|
FROM tickets t
|
|
LEFT JOIN users u ON t.assigned_to = u.user_id
|
|
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL";
|
|
if ($visSQL !== '') {
|
|
$sql .= " AND ($visSQL)";
|
|
}
|
|
$sql .= " GROUP BY t.assigned_to
|
|
ORDER BY open_count DESC
|
|
LIMIT ?";
|
|
|
|
$params = $visParams;
|
|
$params[] = $limit;
|
|
$types = $visTypes . 'i';
|
|
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->bind_param($types, ...$params);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$data = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$data[] = [
|
|
'user_id' => (int)$row['user_id'],
|
|
'display_name' => $row['display_name'] ?: $row['username'],
|
|
'username' => $row['username'],
|
|
'open_count' => (int)$row['open_count'],
|
|
];
|
|
}
|
|
$stmt->close();
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Get all stats as a single array, respecting ticket visibility for the given user.
|
|
*
|
|
* Admins use a shared cache; non-admins use a per-user cache key so confidential
|
|
* tickets are not counted in stats for users who cannot access them.
|
|
*
|
|
* @param array $user Current user array (must include user_id, is_admin, groups)
|
|
* @param bool $forceRefresh Force a cache refresh
|
|
* @return array All dashboard statistics
|
|
*/
|
|
public function getAllStats(array $user = [], bool $forceRefresh = false): array
|
|
{
|
|
$isAdmin = !empty($user['is_admin']);
|
|
// Admins share one cache entry; non-admins get a per-user cache entry
|
|
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
|
|
|
if ($forceRefresh) {
|
|
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
|
}
|
|
|
|
return CacheHelper::remember(
|
|
self::CACHE_PREFIX,
|
|
$cacheKey,
|
|
function () use ($user) {
|
|
return $this->fetchAllStats($user);
|
|
},
|
|
self::STATS_CACHE_TTL
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch all stats from database (uncached), filtered by the given user's visibility.
|
|
*
|
|
* Uses consolidated queries to reduce database round-trips.
|
|
*
|
|
* @param array $user Current user array
|
|
* @return array All dashboard statistics
|
|
*/
|
|
private function fetchAllStats(array $user = []): array
|
|
{
|
|
$ticketModel = new TicketModel($this->conn);
|
|
$visFilter = $ticketModel->getVisibilityFilter($user);
|
|
$visSQL = $visFilter['sql'];
|
|
$visParams = $visFilter['params'];
|
|
$visTypes = $visFilter['types'];
|
|
|
|
// Query 1: Get all simple counts in one query using conditional aggregation
|
|
$countsSql = "SELECT
|
|
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
|
SUM(CASE WHEN status = 'Closed' THEN 1 ELSE 0 END) as closed_tickets,
|
|
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as created_today,
|
|
SUM(CASE WHEN YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1) THEN 1 ELSE 0 END) as created_this_week,
|
|
SUM(CASE WHEN status = 'Closed' AND DATE(closed_at) = CURDATE() THEN 1 ELSE 0 END) as closed_today,
|
|
SUM(CASE WHEN assigned_to IS NULL AND status != 'Closed' THEN 1 ELSE 0 END) as unassigned,
|
|
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
|
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
|
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
|
FROM tickets t WHERE ($visSQL)";
|
|
|
|
if (!empty($visParams)) {
|
|
$stmt = $this->conn->prepare($countsSql);
|
|
$stmt->bind_param($visTypes, ...$visParams);
|
|
$stmt->execute();
|
|
$countsResult = $stmt->get_result();
|
|
$stmt->close();
|
|
} else {
|
|
$countsResult = $this->conn->query($countsSql);
|
|
}
|
|
$counts = $countsResult->fetch_assoc();
|
|
|
|
// Query 2: Get priority, status, and category breakdowns in one query
|
|
$breakdownSql = "SELECT
|
|
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
|
|
UNION ALL
|
|
SELECT 'status' as type, status as label, COUNT(*) as count
|
|
FROM tickets t WHERE ($visSQL) GROUP BY status
|
|
UNION ALL
|
|
SELECT 'category' as type, category as label, COUNT(*) as count
|
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
|
|
|
|
if (!empty($visParams)) {
|
|
// Need to bind params 3 times (once per UNION branch)
|
|
$tripleParams = array_merge($visParams, $visParams, $visParams);
|
|
$tripleTypes = $visTypes . $visTypes . $visTypes;
|
|
$stmt = $this->conn->prepare($breakdownSql);
|
|
$stmt->bind_param($tripleTypes, ...$tripleParams);
|
|
$stmt->execute();
|
|
$breakdownResult = $stmt->get_result();
|
|
$stmt->close();
|
|
} else {
|
|
$breakdownResult = $this->conn->query($breakdownSql);
|
|
}
|
|
|
|
$byPriority = [];
|
|
$byStatus = [];
|
|
$byCategory = [];
|
|
|
|
while ($row = $breakdownResult->fetch_assoc()) {
|
|
switch ($row['type']) {
|
|
case 'priority':
|
|
$byPriority[$row['label']] = (int)$row['count'];
|
|
break;
|
|
case 'status':
|
|
$byStatus[$row['label']] = (int)$row['count'];
|
|
break;
|
|
case 'category':
|
|
$byCategory[$row['label']] = (int)$row['count'];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Sort priority keys
|
|
ksort($byPriority);
|
|
|
|
// Query 3: Get assignee stats (requires JOIN, kept separate). Pass the same
|
|
// visibility filter so confidential tickets aren't counted for non-admins.
|
|
$byAssignee = $this->getTicketsByAssignee(8, $visFilter);
|
|
|
|
return [
|
|
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
|
|
'closed_tickets' => (int)($counts['closed_tickets'] ?? 0),
|
|
'created_today' => (int)($counts['created_today'] ?? 0),
|
|
'created_this_week' => (int)($counts['created_this_week'] ?? 0),
|
|
'closed_today' => (int)($counts['closed_today'] ?? 0),
|
|
'unassigned' => (int)($counts['unassigned'] ?? 0),
|
|
'critical' => (int)($counts['critical'] ?? 0),
|
|
'avg_resolution_hours' => $counts['avg_resolution'] ? round((float)$counts['avg_resolution'], 1) : 0.0,
|
|
'by_priority' => $byPriority,
|
|
'by_status' => $byStatus,
|
|
'by_category' => $byCategory,
|
|
'by_assignee' => $byAssignee
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Invalidate cached stats
|
|
*
|
|
* Call this method when ticket data changes to ensure fresh stats.
|
|
*/
|
|
public function invalidateCache(): void
|
|
{
|
|
CacheHelper::delete(self::CACHE_PREFIX, null);
|
|
}
|
|
}
|