From 9f1a375e5a041366730c51cc364336014220ff15 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 20 Mar 2026 21:44:01 -0400 Subject: [PATCH] Apply visibility filtering to dashboard statistics StatsModel.getAllStats() now accepts a user array and applies the same getVisibilityFilter() logic used by ticket listings. Admins continue to share a single cached result; non-admin users get per-user cache entries so confidential ticket counts are not leaked in dashboard stats. Co-Authored-By: Claude Sonnet 4.6 --- controllers/DashboardController.php | 2 +- models/StatsModel.php | 62 ++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/controllers/DashboardController.php b/controllers/DashboardController.php index 804a8bf..a0bcfe5 100644 --- a/controllers/DashboardController.php +++ b/controllers/DashboardController.php @@ -155,7 +155,7 @@ class DashboardController { $totalPages = $result['pages']; // Load dashboard statistics - $stats = $this->statsModel->getAllStats(); + $stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []); // Load the dashboard view include 'views/DashboardView.php'; diff --git a/models/StatsModel.php b/models/StatsModel.php index e833ba1..df9f5d4 100644 --- a/models/StatsModel.php +++ b/models/StatsModel.php @@ -7,6 +7,7 @@ */ require_once dirname(__DIR__) . '/helpers/CacheHelper.php'; +require_once dirname(__DIR__) . '/models/TicketModel.php'; class StatsModel { private mysqli $conn; @@ -173,15 +174,19 @@ class StatsModel { } /** - * Get all stats as a single array + * Get all stats as a single array, respecting ticket visibility for the given user. * - * Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds. + * 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(bool $forceRefresh = false): array { - $cacheKey = 'dashboard_all'; + 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); @@ -190,21 +195,28 @@ class StatsModel { return CacheHelper::remember( self::CACHE_PREFIX, $cacheKey, - function() { - return $this->fetchAllStats(); + function() use ($user) { + return $this->fetchAllStats($user); }, self::STATS_CACHE_TTL ); } /** - * Fetch all stats from database (uncached) + * Fetch all stats from database (uncached), filtered by the given user's visibility. * - * Uses consolidated queries to reduce database round-trips from 12 to 4. + * Uses consolidated queries to reduce database round-trips. * + * @param array $user Current user array * @return array All dashboard statistics */ - private function fetchAllStats(): array { + 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, @@ -216,23 +228,43 @@ class StatsModel { 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"; + FROM tickets WHERE ($visSQL)"; - $countsResult = $this->conn->query($countsSql); + 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 WHERE status != 'Closed' GROUP BY priority + FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY priority UNION ALL SELECT 'status' as type, status as label, COUNT(*) as count - FROM tickets GROUP BY status + FROM tickets WHERE ($visSQL) GROUP BY status UNION ALL SELECT 'category' as type, category as label, COUNT(*) as count - FROM tickets WHERE status != 'Closed' GROUP BY category"; + FROM tickets 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); + } - $breakdownResult = $this->conn->query($breakdownSql); $byPriority = []; $byStatus = []; $byCategory = [];