Add performance, security, and reliability improvements

- Consolidate all 20 API files to use centralized Database helper
- Add optimistic locking to ticket updates to prevent concurrent conflicts
- Add caching to StatsModel (60s TTL) for dashboard performance
- Add health check endpoint (api/health.php) for monitoring
- Improve rate limit cleanup with cron script and efficient DirectoryIterator
- Enable rate limit response headers (X-RateLimit-*)
- Add audit logging for workflow transitions
- Log Discord webhook failures instead of silencing
- Fix visibility check on export_tickets.php
- Add database migration system with performance indexes
- Fix cron recurring tickets to use assignTicket method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 14:39:13 -05:00
parent c3f7593f3c
commit 7575d6a277
31 changed files with 825 additions and 398 deletions

View File

@@ -2,20 +2,29 @@
/**
* StatsModel - Dashboard statistics and metrics
*
* Provides various ticket statistics for dashboard widgets
* Provides various ticket statistics for dashboard widgets.
* Uses caching to reduce database load for frequently accessed stats.
*/
class StatsModel {
private $conn;
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
public function __construct($conn) {
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 count of open tickets
*/
public function getOpenTicketCount() {
public function getOpenTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -25,7 +34,7 @@ class StatsModel {
/**
* Get count of closed tickets
*/
public function getClosedTicketCount() {
public function getClosedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -35,7 +44,7 @@ class StatsModel {
/**
* Get tickets grouped by priority
*/
public function getTicketsByPriority() {
public function getTicketsByPriority(): array {
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql);
$data = [];
@@ -48,7 +57,7 @@ class StatsModel {
/**
* Get tickets grouped by status
*/
public function getTicketsByStatus() {
public function getTicketsByStatus(): array {
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
$result = $this->conn->query($sql);
$data = [];
@@ -61,7 +70,7 @@ class StatsModel {
/**
* Get tickets grouped by category
*/
public function getTicketsByCategory() {
public function getTicketsByCategory(): array {
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql);
$data = [];
@@ -74,7 +83,7 @@ class StatsModel {
/**
* Get average resolution time in hours
*/
public function getAverageResolutionTime() {
public function getAverageResolutionTime(): float {
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
FROM tickets
WHERE status = 'Closed'
@@ -89,7 +98,7 @@ class StatsModel {
/**
* Get count of tickets created today
*/
public function getTicketsCreatedToday() {
public function getTicketsCreatedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -99,7 +108,7 @@ class StatsModel {
/**
* Get count of tickets created this week
*/
public function getTicketsCreatedThisWeek() {
public function getTicketsCreatedThisWeek(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -109,7 +118,7 @@ class StatsModel {
/**
* Get count of tickets closed today
*/
public function getTicketsClosedToday() {
public function getTicketsClosedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -119,7 +128,7 @@ class StatsModel {
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee($limit = 5) {
public function getTicketsByAssignee(int $limit = 5): array {
$sql = "SELECT
u.display_name,
u.username,
@@ -146,7 +155,7 @@ class StatsModel {
/**
* Get unassigned ticket count
*/
public function getUnassignedTicketCount() {
public function getUnassignedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -156,7 +165,7 @@ class StatsModel {
/**
* Get critical (P1) ticket count
*/
public function getCriticalTicketCount() {
public function getCriticalTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
@@ -165,8 +174,35 @@ class StatsModel {
/**
* Get all stats as a single array
*
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
*
* @param bool $forceRefresh Force a cache refresh
* @return array All dashboard statistics
*/
public function getAllStats() {
public function getAllStats(bool $forceRefresh = false): array {
$cacheKey = 'dashboard_all';
if ($forceRefresh) {
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
}
return CacheHelper::remember(
self::CACHE_PREFIX,
$cacheKey,
function() {
return $this->fetchAllStats();
},
self::STATS_CACHE_TTL
);
}
/**
* Fetch all stats from database (uncached)
*
* @return array All dashboard statistics
*/
private function fetchAllStats(): array {
return [
'open_tickets' => $this->getOpenTicketCount(),
'closed_tickets' => $this->getClosedTicketCount(),
@@ -182,5 +218,13 @@ class StatsModel {
'by_assignee' => $this->getTicketsByAssignee()
];
}
/**
* Invalidate cached stats
*
* Call this method when ticket data changes to ensure fresh stats.
*/
public function invalidateCache(): void {
CacheHelper::delete(self::CACHE_PREFIX, null);
}
}
?>