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 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 14:59:35 -05:00
parent bcc163bc77
commit 13f0fab138
3 changed files with 90 additions and 11 deletions

View File

@@ -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);

View File

@@ -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']
);
}

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env php
<?php
/**
* Migration: Add closed_at column to tickets table
*
* Adds a dedicated timestamp for when tickets are closed,
* so avg resolution time isn't inflated by post-close edits.
*
* Usage: php scripts/add_closed_at_column.php
*/
require_once dirname(__DIR__) . '/config/config.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->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();