From 13f0fab138836bd5f8e0c98c72ff61062d86933f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Feb 2026 14:59:35 -0500 Subject: [PATCH] Fix avg resolution time using dedicated closed_at column The dashboard's "Avg Resolution" stat was using updated_at, which gets overwritten on any post-close edit (title change, comment, etc.), inflating the metric. Also fixes "Closed Today" count for the same reason. - Add closed_at TIMESTAMP column to tickets table - Set closed_at on close, preserve on re-edit, clear on reopen - Update StatsModel queries to use closed_at instead of updated_at - Add migration script with audit log backfill for existing tickets Run: php scripts/add_closed_at_column.php Co-Authored-By: Claude Opus 4.6 --- models/StatsModel.php | 14 +++---- models/TicketModel.php | 15 +++++-- scripts/add_closed_at_column.php | 72 ++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 scripts/add_closed_at_column.php diff --git a/models/StatsModel.php b/models/StatsModel.php index 34bfacc..0c924d5 100644 --- a/models/StatsModel.php +++ b/models/StatsModel.php @@ -84,12 +84,12 @@ class StatsModel { * Get average resolution time in hours */ public function getAverageResolutionTime(): float { - $sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours + $sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours FROM tickets WHERE status = 'Closed' AND created_at IS NOT NULL - AND updated_at IS NOT NULL - AND updated_at > created_at"; + AND closed_at IS NOT NULL + AND closed_at > created_at"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0; @@ -119,7 +119,7 @@ class StatsModel { * Get count of tickets closed today */ public function getTicketsClosedToday(): int { - $sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()"; + $sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()"; $result = $this->conn->query($sql); $row = $result->fetch_assoc(); return (int)$row['count']; @@ -211,11 +211,11 @@ class StatsModel { 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(updated_at) = CURDATE() THEN 1 ELSE 0 END) as closed_today, + 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 updated_at > created_at - THEN TIMESTAMPDIFF(HOUR, created_at, updated_at) ELSE NULL END) as avg_resolution + 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"; $countsResult = $this->conn->query($countsSql); diff --git a/models/TicketModel.php b/models/TicketModel.php index 8d89491..a7b5895 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -216,6 +216,9 @@ class TicketModel { * @return array ['success' => bool, 'error' => string|null, 'conflict' => bool] */ public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array { + // closed_at: set on close (preserve if already set), clear on reopen + $closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END"; + // Build query with optional optimistic locking if ($expectedUpdatedAt !== null) { // Optimistic locking enabled - check that updated_at hasn't changed @@ -227,7 +230,8 @@ class TicketModel { category = ?, type = ?, updated_by = ?, - updated_at = NOW() + updated_at = NOW(), + $closedAtClause WHERE ticket_id = ? AND updated_at = ?"; } else { // No optimistic locking @@ -239,7 +243,8 @@ class TicketModel { category = ?, type = ?, updated_by = ?, - updated_at = NOW() + updated_at = NOW(), + $closedAtClause WHERE ticket_id = ?"; } @@ -250,7 +255,7 @@ class TicketModel { if ($expectedUpdatedAt !== null) { $stmt->bind_param( - "sissssiis", + "sissssisis", $ticketData['title'], $ticketData['priority'], $ticketData['status'], @@ -258,12 +263,13 @@ class TicketModel { $ticketData['category'], $ticketData['type'], $updatedBy, + $ticketData['status'], $ticketData['ticket_id'], $expectedUpdatedAt ); } else { $stmt->bind_param( - "sissssii", + "sissssisi", $ticketData['title'], $ticketData['priority'], $ticketData['status'], @@ -271,6 +277,7 @@ class TicketModel { $ticketData['category'], $ticketData['type'], $updatedBy, + $ticketData['status'], $ticketData['ticket_id'] ); } diff --git a/scripts/add_closed_at_column.php b/scripts/add_closed_at_column.php new file mode 100644 index 0000000..cff637c --- /dev/null +++ b/scripts/add_closed_at_column.php @@ -0,0 +1,72 @@ +#!/usr/bin/env php +connect_error) { + die("Database connection failed: " . $conn->connect_error . "\n"); +} + +echo "Adding closed_at column to tickets table...\n"; + +// Add the column if it doesn't exist +$result = $conn->query("SHOW COLUMNS FROM tickets LIKE 'closed_at'"); +if ($result->num_rows > 0) { + echo "Column 'closed_at' already exists, skipping ALTER TABLE.\n"; +} else { + $sql = "ALTER TABLE tickets ADD COLUMN closed_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at"; + if ($conn->query($sql)) { + echo "Column added successfully.\n"; + } else { + die("Failed to add column: " . $conn->error . "\n"); + } + + // Add index for stats queries + $conn->query("CREATE INDEX idx_tickets_closed_at ON tickets (closed_at)"); + echo "Index created.\n"; +} + +// Backfill: For existing closed tickets, use the audit log to find when they were closed +echo "\nBackfilling closed_at from audit log...\n"; + +$sql = "UPDATE tickets t + JOIN ( + SELECT entity_id as ticket_id, MIN(created_at) as first_closed + FROM audit_log + WHERE entity_type = 'ticket' + AND action_type = 'update' + AND details LIKE '%\"status\":\"Closed\"%' + GROUP BY entity_id + ) al ON t.ticket_id = al.ticket_id + SET t.closed_at = al.first_closed + WHERE t.status = 'Closed' AND t.closed_at IS NULL"; + +$result = $conn->query($sql); +$backfilled = $conn->affected_rows; +echo "Backfilled $backfilled tickets from audit log.\n"; + +// For any remaining closed tickets without audit log entries, use updated_at as fallback +$sql = "UPDATE tickets SET closed_at = updated_at WHERE status = 'Closed' AND closed_at IS NULL"; +$conn->query($sql); +$fallback = $conn->affected_rows; +if ($fallback > 0) { + echo "Used updated_at as fallback for $fallback tickets without audit log entries.\n"; +} + +echo "\nMigration complete!\n"; +$conn->close();