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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user