Files
tinker_tickets/models/StatsModel.php
T
jared 2fdd42b45b UX and architecture fixes: bulk-delete, template guard, statuses config
Bug fixes:
- bulk-delete action called undefined bulkDelete() — wired to the
  existing showBulkDeleteModal() so the confirmation modal actually shows

UX:
- Template loader now checks for existing title/description and asks
  for confirmation before overwriting user-typed content
- Visibility select shows a dynamic hint paragraph that updates when
  the user changes the selection (public/internal/confidential)

Architecture:
- TICKET_STATUSES added to config as single source of truth; all
  hardcoded ['Open','Pending','In Progress','Closed'] arrays in
  DashboardView now read from config; bulk-status modal in dashboard.js
  reads window.TICKET_STATUSES (set from PHP) with array fallback
- ASSET_VERSION now auto-computed from max mtime of dashboard/ticket
  CSS+JS files so browsers always pick up changes on deploy; manual
  override still available via ASSET_VERSION in .env
- Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount,
  getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by
  the consolidated fetchAllStats() queries, never called externally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:09:29 -04:00

193 lines
7.4 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 = 5): array {
$sql = "SELECT
u.display_name,
u.username,
COUNT(t.ticket_id) as ticket_count
FROM tickets t
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
GROUP BY t.assigned_to
ORDER BY ticket_count DESC
LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) {
$name = $row['display_name'] ?: $row['username'];
$data[$name] = (int)$row['ticket_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)
$byAssignee = $this->getTicketsByAssignee();
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);
}
}