Files
tinker_tickets/controllers/DashboardController.php
Jared Vititoe 44f2c21f2d Add query optimization and reliability improvements
- Consolidate StatsModel queries from 12 to 3 using conditional aggregation
- Add input validation to DashboardController (sort columns, dates, priorities)
- Combine getCategories/getTypes into single query
- Add transaction support to BulkOperationsModel with atomic mode option
- Add depth limit (20) to dependency cycle detection to prevent DoS
- Add caching to UserModel.getAllGroups() with 5-minute TTL
- Improve ticket ID generation with 50 attempts, exponential backoff, and fallback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:31:46 -05:00

198 lines
7.4 KiB
PHP

<?php
require_once 'models/TicketModel.php';
require_once 'models/UserPreferencesModel.php';
require_once 'models/StatsModel.php';
class DashboardController {
private $ticketModel;
private $prefsModel;
private $statsModel;
private $conn;
/** Valid sort columns (whitelist) */
private const VALID_SORT_COLUMNS = [
'ticket_id', 'title', 'status', 'priority', 'category', 'type',
'created_at', 'updated_at', 'assigned_to', 'created_by'
];
/** Valid statuses */
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
public function __construct($conn) {
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->prefsModel = new UserPreferencesModel($conn);
$this->statsModel = new StatsModel($conn);
}
/**
* Validate and sanitize a date string
*/
private function validateDate(?string $date): ?string {
if (empty($date)) {
return null;
}
// Check if it's a valid date format (YYYY-MM-DD)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && strtotime($date) !== false) {
return $date;
}
return null;
}
/**
* Validate priority value (1-5)
*/
private function validatePriority($priority): ?int {
if ($priority === null || $priority === '') {
return null;
}
$val = (int)$priority;
return ($val >= 1 && $val <= 5) ? $val : null;
}
/**
* Validate user ID
*/
private function validateUserId($userId): ?int {
if ($userId === null || $userId === '') {
return null;
}
$val = (int)$userId;
return ($val > 0) ? $val : null;
}
public function index() {
// Get user ID for preferences
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
// Validate and sanitize page parameter
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
// Get rows per page from user preferences, fallback to cookie, then default
// Clamp to reasonable range (1-100)
$limit = 15;
if ($userId) {
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
} else if (isset($_COOKIE['ticketsPerPage'])) {
$limit = (int)$_COOKIE['ticketsPerPage'];
}
$limit = max(1, min(100, $limit));
// Validate sort column against whitelist
$sortColumn = isset($_GET['sort']) && in_array($_GET['sort'], self::VALID_SORT_COLUMNS, true)
? $_GET['sort']
: 'ticket_id';
// Validate sort direction
$sortDirection = isset($_GET['dir']) && strtolower($_GET['dir']) === 'asc' ? 'asc' : 'desc';
// Category and type are validated by the model (uses prepared statements)
$category = isset($_GET['category']) ? trim($_GET['category']) : null;
$type = isset($_GET['type']) ? trim($_GET['type']) : null;
// Sanitize search - limit length to prevent abuse
$search = isset($_GET['search']) ? substr(trim($_GET['search']), 0, 255) : null;
// Handle status filtering with user preferences
$status = null;
if (isset($_GET['status']) && !empty($_GET['status'])) {
// Validate each status in the comma-separated list
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
$validStatuses = array_filter($requestedStatuses, function($s) {
return in_array($s, self::VALID_STATUSES, true);
});
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
} else if (!isset($_GET['show_all'])) {
// Get default status filters from user preferences
if ($userId) {
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
} else {
// Default: show Open, Pending, and In Progress (exclude Closed)
$status = 'Open,Pending,In Progress';
}
}
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
// Build and validate advanced search filters
$filters = [];
// Validate date filters
$createdFrom = $this->validateDate($_GET['created_from'] ?? null);
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
if ($createdFrom) $filters['created_from'] = $createdFrom;
if ($createdTo) $filters['created_to'] = $createdTo;
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
if ($updatedTo) $filters['updated_to'] = $updatedTo;
// Validate priority filters
$priorityMin = $this->validatePriority($_GET['priority_min'] ?? null);
$priorityMax = $this->validatePriority($_GET['priority_max'] ?? null);
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
// Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
if ($createdBy !== null) $filters['created_by'] = $createdBy;
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
// Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
// Get categories and types for filters (single query)
$filterOptions = $this->getCategoriesAndTypes();
$categories = $filterOptions['categories'];
$types = $filterOptions['types'];
// Extract data for the view
$tickets = $result['tickets'];
$totalTickets = $result['total'];
$totalPages = $result['pages'];
// Load dashboard statistics
$stats = $this->statsModel->getAllStats();
// Load the dashboard view
include 'views/DashboardView.php';
}
/**
* Get categories and types in a single query
*
* @return array ['categories' => [...], 'types' => [...]]
*/
private function getCategoriesAndTypes(): array {
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
UNION
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
ORDER BY field, value";
$result = $this->conn->query($sql);
$categories = [];
$types = [];
while ($row = $result->fetch_assoc()) {
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
$categories[] = $row['value'];
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
$types[] = $row['value'];
}
}
return ['categories' => $categories, 'types' => $types];
}
private function getCategories(): array {
return $this->getCategoriesAndTypes()['categories'];
}
private function getTypes(): array {
return $this->getCategoriesAndTypes()['types'];
}
}
?>