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 <noreply@anthropic.com>
This commit is contained in:
@@ -155,7 +155,7 @@ class DashboardController {
|
|||||||
$totalPages = $result['pages'];
|
$totalPages = $result['pages'];
|
||||||
|
|
||||||
// Load dashboard statistics
|
// Load dashboard statistics
|
||||||
$stats = $this->statsModel->getAllStats();
|
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
|
||||||
|
|
||||||
// Load the dashboard view
|
// Load the dashboard view
|
||||||
include 'views/DashboardView.php';
|
include 'views/DashboardView.php';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
class StatsModel {
|
class StatsModel {
|
||||||
private mysqli $conn;
|
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
|
* @param bool $forceRefresh Force a cache refresh
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
public function getAllStats(bool $forceRefresh = false): array {
|
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
|
||||||
$cacheKey = 'dashboard_all';
|
$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) {
|
if ($forceRefresh) {
|
||||||
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
||||||
@@ -190,21 +195,28 @@ class StatsModel {
|
|||||||
return CacheHelper::remember(
|
return CacheHelper::remember(
|
||||||
self::CACHE_PREFIX,
|
self::CACHE_PREFIX,
|
||||||
$cacheKey,
|
$cacheKey,
|
||||||
function() {
|
function() use ($user) {
|
||||||
return $this->fetchAllStats();
|
return $this->fetchAllStats($user);
|
||||||
},
|
},
|
||||||
self::STATS_CACHE_TTL
|
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
|
* @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
|
// Query 1: Get all simple counts in one query using conditional aggregation
|
||||||
$countsSql = "SELECT
|
$countsSql = "SELECT
|
||||||
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
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,
|
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
|
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
||||||
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
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();
|
$counts = $countsResult->fetch_assoc();
|
||||||
|
|
||||||
// Query 2: Get priority, status, and category breakdowns in one query
|
// Query 2: Get priority, status, and category breakdowns in one query
|
||||||
$breakdownSql = "SELECT
|
$breakdownSql = "SELECT
|
||||||
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
'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
|
UNION ALL
|
||||||
SELECT 'status' as type, status as label, COUNT(*) as count
|
SELECT 'status' as type, status as label, COUNT(*) as count
|
||||||
FROM tickets GROUP BY status
|
FROM tickets WHERE ($visSQL) GROUP BY status
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'category' as type, category as label, COUNT(*) as count
|
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 = [];
|
$byPriority = [];
|
||||||
$byStatus = [];
|
$byStatus = [];
|
||||||
$byCategory = [];
|
$byCategory = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user