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

@@ -83,7 +83,7 @@ class BulkOperationsModel {
// Get current ticket from pre-loaded batch
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$success = $ticketModel->updateTicket([
$updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId,
'title' => $currentTicket['title'],
'description' => $currentTicket['description'],
@@ -92,6 +92,7 @@ class BulkOperationsModel {
'status' => 'Closed',
'priority' => $currentTicket['priority']
], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
@@ -114,7 +115,7 @@ class BulkOperationsModel {
if (isset($parameters['priority'])) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$success = $ticketModel->updateTicket([
$updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId,
'title' => $currentTicket['title'],
'description' => $currentTicket['description'],
@@ -123,6 +124,7 @@ class BulkOperationsModel {
'status' => $currentTicket['status'],
'priority' => $parameters['priority']
], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
@@ -136,7 +138,7 @@ class BulkOperationsModel {
if (isset($parameters['status'])) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$success = $ticketModel->updateTicket([
$updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId,
'title' => $currentTicket['title'],
'description' => $currentTicket['description'],
@@ -145,6 +147,7 @@ class BulkOperationsModel {
'status' => $parameters['status'],
'priority' => $currentTicket['priority']
], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,

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);
}
}
?>

View File

@@ -222,39 +222,99 @@ class TicketModel {
];
}
public function updateTicket(array $ticketData, ?int $updatedBy = null): bool {
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW()
WHERE ticket_id = ?";
/**
* Update a ticket with optional optimistic locking
*
* @param array $ticketData Ticket data including ticket_id
* @param int|null $updatedBy User ID performing the update
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
*/
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
// Build query with optional optimistic locking
if ($expectedUpdatedAt !== null) {
// Optimistic locking enabled - check that updated_at hasn't changed
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW()
WHERE ticket_id = ? AND updated_at = ?";
} else {
// No optimistic locking
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW()
WHERE ticket_id = ?";
}
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
return false;
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
}
$stmt->bind_param(
"sissssii",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id']
);
if ($expectedUpdatedAt !== null) {
$stmt->bind_param(
"sissssiis",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id'],
$expectedUpdatedAt
);
} else {
$stmt->bind_param(
"sissssii",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id']
);
}
$result = $stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
return $result;
if (!$result) {
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
}
// Check for optimistic locking conflict
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
// Either ticket doesn't exist or was modified by someone else
$ticket = $this->getTicketById($ticketData['ticket_id']);
if ($ticket) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
} else {
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
}
}
return ['success' => true, 'error' => null, 'conflict' => false];
}
public function createTicket(array $ticketData, ?int $createdBy = null): array {