diff --git a/agent/ticket_list.php b/agent/ticket_list.php
index b2c5a3fb..2107acef 100644
--- a/agent/ticket_list.php
+++ b/agent/ticket_list.php
@@ -90,6 +90,8 @@
$ticket_updated_at = escapeHtml($row['ticket_updated_at']);
$ticket_updated_at_time_ago = timeAgo($row['ticket_updated_at']);
$ticket_closed_at = escapeHtml($row['ticket_closed_at']);
+ // SLA alert stages are maintained by cron/ticket_sla.php (1 = warned, 2 = breached)
+ $ticket_sla_alert_stage = max(intval($row['ticket_response_sla_alert_stage']), intval($row['ticket_resolution_sla_alert_stage']));
if (empty($ticket_updated_at)) {
if (!empty($ticket_closed_at)) {
$ticket_updated_at_display = "
";
@@ -192,7 +194,7 @@
?>
-
">
diff --git a/api/v1/tickets/create.php b/api/v1/tickets/create.php
index 339b8192..2ced44ea 100644
--- a/api/v1/tickets/create.php
+++ b/api/v1/tickets/create.php
@@ -50,6 +50,7 @@ if (!empty($subject)) {
// Check insert & get insert ID
if ($insert_sql) {
$insert_id = mysqli_insert_id($mysqli);
+ applyTicketSla($insert_id);
// Logging
logAudit("Ticket", "Create", "Created ticket $config_ticket_prefix$ticket_number $subject via API ($api_key_name)", $client_id, $insert_id);
diff --git a/api/v1/tickets/resolve.php b/api/v1/tickets/resolve.php
index 6cd2ecf8..4fe4c46d 100644
--- a/api/v1/tickets/resolve.php
+++ b/api/v1/tickets/resolve.php
@@ -25,11 +25,12 @@ if (!empty($ticket_id)) {
// Mark FR (if not)
if (empty($ticket_first_response_at)) {
- mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
+ setTicketFirstResponse($ticket_id);
}
// Resolve
$update_sql = mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 4, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
+ setTicketResolutionSlaMet($ticket_id);
// Check insert & get insert ID
if ($update_sql) {
diff --git a/client/post.php b/client/post.php
index a8cf5a71..77931dbb 100644
--- a/client/post.php
+++ b/client/post.php
@@ -49,6 +49,7 @@ if (isset($_POST['add_ticket'])) {
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Portal', ticket_category = $category, ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = $session_user_id, ticket_contact_id = $session_contact_id, ticket_asset_id = $asset, ticket_url_key = '$url_key', ticket_client_id = $session_client_id");
$ticket_id = mysqli_insert_id($mysqli);
+ applyTicketSla($ticket_id);
// Notify agent DL of the new ticket, if populated with a valid email
if ($config_ticket_new_ticket_notification_email) {
diff --git a/cron/cron.php b/cron/cron.php
index 15cd603d..ba7e8578 100644
--- a/cron/cron.php
+++ b/cron/cron.php
@@ -343,6 +343,7 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) {
// Raise the ticket
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = '$ticket_status', ticket_billable = $billable, ticket_created_by = $created_id, ticket_assigned_to = $assigned_id, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category, ticket_recurring_ticket_id = $recurring_ticket_id");
$id = mysqli_insert_id($mysqli);
+ applyTicketSla($id);
// Copy Additional Assets from Recurring ticket to new ticket
mysqli_query($mysqli, "INSERT INTO ticket_assets (ticket_id, asset_id)
diff --git a/cron/ticket_email_parser.php b/cron/ticket_email_parser.php
index 119a5bf5..4926ae1e 100644
--- a/cron/ticket_email_parser.php
+++ b/cron/ticket_email_parser.php
@@ -124,6 +124,7 @@ function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix_esc', ticket_number = $ticket_number, ticket_source = 'Email', ticket_subject = '$subject', ticket_details = '$message_esc', ticket_priority = 'Low', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = 0, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_client_id = $client_id");
$id = mysqli_insert_id($mysqli);
+ applyTicketSla($id);
// Logging
logAudit("Ticket", "Create", "Email parser: Client contact $contact_email_esc created ticket $ticket_prefix_esc$ticket_number ($subject) ($id)", $client_id, $id);
diff --git a/cron/ticket_sla.php b/cron/ticket_sla.php
new file mode 100644
index 00000000..a6c450da
--- /dev/null
+++ b/cron/ticket_sla.php
@@ -0,0 +1,163 @@
+ 99) {
+ $warning_percent = 0; // Out of range = warnings disabled, breach alerts only
+}
+
+$sla_notification_email = trim(strval($sla_settings['notification_email']));
+$from_email = $sla_settings['ticket_from_email'];
+$from_name = $sla_settings['ticket_from_name'];
+
+$now = time();
+
+// Queue in-app + email notifications for an SLA event
+function sendSlaAlert($ticket, $subject_line, $body_line)
+{
+ global $mysqli, $sla_notification_email, $from_email, $from_name, $config_base_url;
+
+ $ticket_id = intval($ticket['ticket_id']);
+ $client_id = intval($ticket['ticket_client_id']);
+
+ $ticket_ref = "{$ticket['ticket_prefix']}{$ticket['ticket_number']}";
+ $ticket_subject = strval($ticket['ticket_subject']);
+
+ // appNotify inserts what it is given - escape at the boundary
+ appNotify("Ticket SLA", escapeSql("$subject_line - $ticket_ref - $ticket_subject"), "/agent/ticket.php?ticket_id=$ticket_id", $client_id, $ticket_id);
+
+ // addToMailQueue also inserts raw, and the body's link markup contains
+ // single quotes - escape whole strings once here. The body keeps its HTML,
+ // so it gets mysqli_real_escape_string directly (escapeSql strips tags),
+ // same as the ticket email parser does for its bodies.
+ $email_subject = escapeSql("$subject_line: $ticket_ref - $ticket_subject");
+ $email_body = mysqli_real_escape_string($mysqli, "Hello,
$body_line
Ticket: $ticket_ref Subject: $ticket_subject
View ticket");
+
+ $email_data = [];
+
+ if (!empty($sla_notification_email)) {
+ $email_data[] = [
+ 'from' => $from_email,
+ 'from_name' => escapeSql($from_name),
+ 'recipient' => $sla_notification_email,
+ 'recipient_name' => 'SLA Notifications',
+ 'subject' => $email_subject,
+ 'body' => $email_body,
+ ];
+ }
+
+ // Assigned agent (skip a duplicate if they are also the notification address)
+ if (!empty($ticket['user_email']) && strtolower($ticket['user_email']) != strtolower($sla_notification_email)) {
+ $email_data[] = [
+ 'from' => $from_email,
+ 'from_name' => escapeSql($from_name),
+ 'recipient' => $ticket['user_email'],
+ 'recipient_name' => escapeSql(strval($ticket['user_name'])),
+ 'subject' => $email_subject,
+ 'body' => $email_body,
+ ];
+ }
+
+ if (!empty($email_data)) {
+ addToMailQueue($email_data);
+ }
+}
+
+// --- Response SLA track ---
+// Open tickets awaiting a first response, not yet marked breached
+$sql_response = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_created_at, ticket_response_due_at, ticket_response_sla_alert_stage, sla_response_minutes, user_email, user_name
+ FROM tickets
+ LEFT JOIN slas ON ticket_sla_id = sla_id
+ LEFT JOIN users ON ticket_assigned_to = user_id
+ WHERE ticket_sla_id > 0
+ AND ticket_response_due_at IS NOT NULL
+ AND ticket_first_response_at IS NULL
+ AND ticket_resolved_at IS NULL
+ AND ticket_closed_at IS NULL
+ AND ticket_archived_at IS NULL
+ AND ticket_response_sla_alert_stage < 2"
+);
+
+while ($ticket = mysqli_fetch_assoc($sql_response)) {
+
+ $ticket_id = intval($ticket['ticket_id']);
+ $stage = intval($ticket['ticket_response_sla_alert_stage']);
+ $due = strtotime($ticket['ticket_response_due_at']);
+
+ if ($now >= $due) {
+ // Breached without a response - the verdict is final, record the miss
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_response_sla_alert_stage = 2, ticket_response_sla_met = 0 WHERE ticket_id = $ticket_id");
+ sendSlaAlert($ticket, "Response SLA breached", "The response SLA on this ticket was missed (due {$ticket['ticket_response_due_at']}).");
+
+ } elseif ($stage < 1 && $warning_percent) {
+ $warn_at = strtotime(addBusinessMinutes($ticket['ticket_created_at'], floor(intval($ticket['sla_response_minutes']) * $warning_percent / 100)));
+ if ($now >= $warn_at) {
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_response_sla_alert_stage = 1 WHERE ticket_id = $ticket_id");
+ sendSlaAlert($ticket, "Response SLA at risk", "This ticket is approaching its response SLA (due {$ticket['ticket_response_due_at']}).");
+ }
+ }
+}
+
+// --- Resolution SLA track ---
+// Open tickets with a resolution target, not yet resolved or marked breached
+$sql_resolution = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_created_at, ticket_resolution_due_at, ticket_resolution_sla_alert_stage, sla_resolution_minutes, user_email, user_name
+ FROM tickets
+ LEFT JOIN slas ON ticket_sla_id = sla_id
+ LEFT JOIN users ON ticket_assigned_to = user_id
+ WHERE ticket_sla_id > 0
+ AND ticket_resolution_due_at IS NOT NULL
+ AND ticket_resolved_at IS NULL
+ AND ticket_closed_at IS NULL
+ AND ticket_archived_at IS NULL
+ AND ticket_resolution_sla_alert_stage < 2"
+);
+
+while ($ticket = mysqli_fetch_assoc($sql_resolution)) {
+
+ $ticket_id = intval($ticket['ticket_id']);
+ $stage = intval($ticket['ticket_resolution_sla_alert_stage']);
+ $due = strtotime($ticket['ticket_resolution_due_at']);
+
+ if ($now >= $due) {
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_alert_stage = 2, ticket_resolution_sla_met = 0 WHERE ticket_id = $ticket_id");
+ sendSlaAlert($ticket, "Resolution SLA breached", "The resolution SLA on this ticket was missed (due {$ticket['ticket_resolution_due_at']}).");
+
+ } elseif ($stage < 1 && $warning_percent) {
+ $warn_at = strtotime(addBusinessMinutes($ticket['ticket_created_at'], floor(intval($ticket['sla_resolution_minutes']) * $warning_percent / 100)));
+ if ($now >= $warn_at) {
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_alert_stage = 1 WHERE ticket_id = $ticket_id");
+ sendSlaAlert($ticket, "Resolution SLA at risk", "This ticket is approaching its resolution SLA (due {$ticket['ticket_resolution_due_at']}).");
+ }
+ }
+}
diff --git a/db.sql b/db.sql
index 9530026b..3b78a8e7 100644
--- a/db.sql
+++ b/db.sql
@@ -2213,6 +2213,11 @@ CREATE TABLE `settings` (
`config_theme` varchar(200) DEFAULT 'blue',
`config_telemetry` tinyint(1) DEFAULT 0,
`config_timezone` varchar(200) NOT NULL DEFAULT 'America/New_York',
+ `config_business_days` varchar(20) NOT NULL DEFAULT '1,2,3,4,5',
+ `config_business_hours_start` time NOT NULL DEFAULT '09:00:00',
+ `config_business_hours_end` time NOT NULL DEFAULT '17:00:00',
+ `config_sla_warning_percent` tinyint(3) NOT NULL DEFAULT 75,
+ `config_sla_notification_email` varchar(200) DEFAULT NULL,
`config_destructive_deletes_enable` tinyint(1) NOT NULL DEFAULT 0,
`config_whitelabel_enabled` int(11) NOT NULL DEFAULT 0,
`config_whitelabel_key` text DEFAULT NULL,
@@ -2249,6 +2254,42 @@ CREATE TABLE `shared_items` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
+--
+-- Table structure for table `sla_assignments`
+--
+
+DROP TABLE IF EXISTS `sla_assignments`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8mb4 */;
+CREATE TABLE `sla_assignments` (
+ `sla_assignment_id` int(11) NOT NULL AUTO_INCREMENT,
+ `sla_assignment_client_id` int(11) NOT NULL DEFAULT 0,
+ `sla_assignment_priority` varchar(200) NOT NULL,
+ `sla_assignment_sla_id` int(11) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`sla_assignment_id`),
+ UNIQUE KEY `sla_assignment_client_priority` (`sla_assignment_client_id`,`sla_assignment_priority`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `slas`
+--
+
+DROP TABLE IF EXISTS `slas`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8mb4 */;
+CREATE TABLE `slas` (
+ `sla_id` int(11) NOT NULL AUTO_INCREMENT,
+ `sla_name` varchar(200) NOT NULL,
+ `sla_description` varchar(500) DEFAULT NULL,
+ `sla_response_minutes` int(11) NOT NULL,
+ `sla_resolution_minutes` int(11) DEFAULT NULL,
+ `sla_created_at` datetime NOT NULL DEFAULT current_timestamp(),
+ `sla_archived_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`sla_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
--
-- Table structure for table `software`
--
@@ -2700,6 +2741,7 @@ CREATE TABLE `tickets` (
`ticket_details` longtext NOT NULL,
`ticket_priority` varchar(200) DEFAULT NULL,
`ticket_status` int(11) NOT NULL,
+ `ticket_sla_id` int(11) NOT NULL DEFAULT 0,
`ticket_billable` tinyint(1) NOT NULL DEFAULT 0,
`ticket_schedule` datetime DEFAULT NULL,
`ticket_onsite` tinyint(1) NOT NULL DEFAULT 0,
@@ -2712,6 +2754,12 @@ CREATE TABLE `tickets` (
`ticket_resolved_at` datetime DEFAULT NULL,
`ticket_archived_at` datetime DEFAULT NULL,
`ticket_first_response_at` datetime DEFAULT NULL,
+ `ticket_response_due_at` datetime DEFAULT NULL,
+ `ticket_resolution_due_at` datetime DEFAULT NULL,
+ `ticket_response_sla_met` tinyint(1) DEFAULT NULL,
+ `ticket_resolution_sla_met` tinyint(1) DEFAULT NULL,
+ `ticket_response_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0,
+ `ticket_resolution_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0,
`ticket_closed_at` datetime DEFAULT NULL,
`ticket_created_by` int(11) NOT NULL,
`ticket_assigned_to` int(11) NOT NULL DEFAULT 0,
@@ -2726,7 +2774,9 @@ CREATE TABLE `tickets` (
`ticket_project_id` int(11) NOT NULL DEFAULT 0,
`ticket_recurring_ticket_id` int(11) DEFAULT 0,
`ticket_order` int(11) NOT NULL DEFAULT 0,
- PRIMARY KEY (`ticket_id`)
+ PRIMARY KEY (`ticket_id`),
+ KEY `ticket_response_due_at` (`ticket_response_due_at`),
+ KEY `ticket_resolution_due_at` (`ticket_resolution_due_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
diff --git a/functions.php b/functions.php
index 5183cccc..8f9be24d 100644
--- a/functions.php
+++ b/functions.php
@@ -17,3 +17,4 @@ require_once __DIR__ . '/functions/logging.php';
require_once __DIR__ . '/functions/app.php';
require_once __DIR__ . '/functions/db.php';
require_once __DIR__ . '/functions/payments.php';
+require_once __DIR__ . '/functions/sla.php';
diff --git a/functions/sla.php b/functions/sla.php
new file mode 100644
index 00000000..0fee1dfc
--- /dev/null
+++ b/functions/sla.php
@@ -0,0 +1,280 @@
+= 1 && $day <= 7) {
+ $business_days[] = $day;
+ }
+ }
+
+ $sla_settings = [
+ 'business_days' => $business_days,
+ 'business_hours_start' => $row['config_business_hours_start'],
+ 'business_hours_end' => $row['config_business_hours_end'],
+ 'warning_percent' => intval($row['config_sla_warning_percent']),
+ 'notification_email' => $row['config_sla_notification_email'],
+ 'ticket_from_name' => $row['config_ticket_from_name'] ?: $row['config_mail_from_name'],
+ 'ticket_from_email' => $row['config_ticket_from_email'] ?: $row['config_mail_from_email'],
+ ];
+
+ return $sla_settings;
+}
+
+// Add $minutes of business time to a datetime, honouring the configured
+// business days and hours. Returns a Y-m-d H:i:s string in the app timezone
+// (includes/inc_set_timezone.php has already set it). With no usable business
+// calendar configured the clock is treated as 24x7.
+function addBusinessMinutes($start_datetime, $minutes)
+{
+ $minutes = intval($minutes);
+ $cursor = new DateTime($start_datetime);
+
+ if ($minutes <= 0) {
+ return $cursor->format('Y-m-d H:i:s');
+ }
+
+ $sla_settings = getSlaSettings();
+ $business_days = $sla_settings['business_days'];
+ $day_start = $sla_settings['business_hours_start'];
+ $day_end = $sla_settings['business_hours_end'];
+
+ if (empty($business_days) || empty($day_start) || empty($day_end) || $day_start >= $day_end) {
+ $cursor->modify("+$minutes minutes");
+ return $cursor->format('Y-m-d H:i:s');
+ }
+
+ $remaining_seconds = $minutes * 60;
+
+ // Walk forward a day at a time consuming available business time. Interval
+ // math is done on timestamps (real elapsed time), window edges by wall
+ // clock - so a DST-shortened business day yields less SLA time, which is
+ // the honest reading. Guard: two years of calendar.
+ for ($i = 0; $i < 731; $i++) {
+
+ if (in_array(intval($cursor->format('N')), $business_days)) {
+
+ $window_start = new DateTime($cursor->format('Y-m-d') . " $day_start");
+ $window_end = new DateTime($cursor->format('Y-m-d') . " $day_end");
+
+ if ($cursor < $window_start) {
+ $cursor = $window_start;
+ }
+
+ if ($cursor < $window_end) {
+ $available_seconds = $window_end->getTimestamp() - $cursor->getTimestamp();
+
+ if ($remaining_seconds <= $available_seconds) {
+ $cursor->setTimestamp($cursor->getTimestamp() + $remaining_seconds);
+ return $cursor->format('Y-m-d H:i:s');
+ }
+
+ $remaining_seconds -= $available_seconds;
+ }
+ }
+
+ // Start of the next calendar day
+ $cursor = new DateTime($cursor->format('Y-m-d') . ' 00:00:00');
+ $cursor->modify('+1 day');
+ }
+
+ // Unreachable with a sane configuration - fail open rather than loop
+ $cursor->setTimestamp($cursor->getTimestamp() + $remaining_seconds);
+ return $cursor->format('Y-m-d H:i:s');
+}
+
+// Resolve which SLA (if any) applies to a client + priority combination.
+// Returns an sla_id, or 0 when no SLA applies.
+function getTicketSlaId($client_id, $priority)
+{
+ global $mysqli;
+
+ $client_id = intval($client_id);
+ $priority = escapeSql($priority);
+
+ $sla_id = null;
+
+ // Client-level assignment wins; a row pointing at SLA 0 is an explicit
+ // "no SLA for this client/priority" override of the global default
+ if ($client_id > 0) {
+ $sql = mysqli_query($mysqli, "SELECT sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = $client_id AND sla_assignment_priority = '$priority' LIMIT 1");
+ if (mysqli_num_rows($sql)) {
+ $sla_id = intval(mysqli_fetch_assoc($sql)['sla_assignment_sla_id']);
+ }
+ }
+
+ // Fall back to the global default (client 0)
+ if (is_null($sla_id)) {
+ $sql = mysqli_query($mysqli, "SELECT sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = 0 AND sla_assignment_priority = '$priority' LIMIT 1");
+ if (mysqli_num_rows($sql)) {
+ $sla_id = intval(mysqli_fetch_assoc($sql)['sla_assignment_sla_id']);
+ }
+ }
+
+ if (empty($sla_id)) {
+ return 0;
+ }
+
+ // Ignore assignments pointing at archived SLAs
+ $sql = mysqli_query($mysqli, "SELECT sla_id FROM slas WHERE sla_id = $sla_id AND sla_archived_at IS NULL LIMIT 1");
+ if (!mysqli_num_rows($sql)) {
+ return 0;
+ }
+
+ return $sla_id;
+}
+
+// Stamp (or re-stamp) a ticket's SLA and computed due dates. Call after ticket
+// creation and after anything that changes which SLA applies (priority edit,
+// client change, manual SLA change). Pass $forced_sla_id to pin a specific SLA
+// (0 = explicitly none) instead of resolving from the assignments.
+function applyTicketSla($ticket_id, $forced_sla_id = null)
+{
+ global $mysqli;
+
+ $ticket_id = intval($ticket_id);
+
+ $sql = mysqli_query($mysqli, "SELECT ticket_client_id, ticket_priority, ticket_created_at, ticket_first_response_at, ticket_resolved_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
+ if (!$sql || !mysqli_num_rows($sql)) {
+ return;
+ }
+ $row = mysqli_fetch_assoc($sql);
+
+ if (is_null($forced_sla_id)) {
+ $sla_id = getTicketSlaId($row['ticket_client_id'], $row['ticket_priority']);
+ } else {
+ $sla_id = intval($forced_sla_id);
+ }
+
+ // No SLA applies - clear any previous targets
+ if ($sla_id == 0) {
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_sla_id = 0, ticket_response_due_at = NULL, ticket_resolution_due_at = NULL, ticket_response_sla_met = NULL, ticket_resolution_sla_met = NULL, ticket_response_sla_alert_stage = 0, ticket_resolution_sla_alert_stage = 0 WHERE ticket_id = $ticket_id");
+ return;
+ }
+
+ $sla_sql = mysqli_query($mysqli, "SELECT sla_response_minutes, sla_resolution_minutes FROM slas WHERE sla_id = $sla_id LIMIT 1");
+ if (!$sla_sql || !mysqli_num_rows($sla_sql)) {
+ return;
+ }
+ $sla = mysqli_fetch_assoc($sla_sql);
+
+ $created_at = $row['ticket_created_at'];
+
+ $response_due_at = addBusinessMinutes($created_at, $sla['sla_response_minutes']);
+
+ $resolution_due_at = null;
+ $resolution_due_at_set = "NULL";
+ if (intval($sla['sla_resolution_minutes']) > 0) {
+ $resolution_due_at = addBusinessMinutes($created_at, $sla['sla_resolution_minutes']);
+ $resolution_due_at_set = "'$resolution_due_at'";
+ }
+
+ // Re-judge met flags for milestones already reached; alert stages reset so
+ // the SLA cron re-evaluates pending milestones against the new targets
+ $response_met_set = "NULL";
+ if (!empty($row['ticket_first_response_at'])) {
+ $response_met_set = strtotime($row['ticket_first_response_at']) <= strtotime($response_due_at) ? 1 : 0;
+ }
+
+ $resolution_met_set = "NULL";
+ if (!empty($row['ticket_resolved_at']) && !is_null($resolution_due_at)) {
+ $resolution_met_set = strtotime($row['ticket_resolved_at']) <= strtotime($resolution_due_at) ? 1 : 0;
+ }
+
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_sla_id = $sla_id, ticket_response_due_at = '$response_due_at', ticket_resolution_due_at = $resolution_due_at_set, ticket_response_sla_met = $response_met_set, ticket_resolution_sla_met = $resolution_met_set, ticket_response_sla_alert_stage = 0, ticket_resolution_sla_alert_stage = 0 WHERE ticket_id = $ticket_id");
+}
+
+// Record the ticket's first response (if not already recorded) and judge the
+// response SLA against the stored due date. Replaces the previous inline
+// ticket_first_response_at updates so the SLA verdict can never drift from
+// the timestamp.
+function setTicketFirstResponse($ticket_id)
+{
+ global $mysqli;
+
+ $ticket_id = intval($ticket_id);
+
+ $sql = mysqli_query($mysqli, "SELECT ticket_first_response_at, ticket_response_due_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
+ if (!$sql || !mysqli_num_rows($sql)) {
+ return;
+ }
+ $row = mysqli_fetch_assoc($sql);
+
+ if (!empty($row['ticket_first_response_at'])) {
+ return;
+ }
+
+ $response_met_set = "NULL";
+ if (!empty($row['ticket_response_due_at'])) {
+ $response_met_set = time() <= strtotime($row['ticket_response_due_at']) ? 1 : 0;
+ }
+
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW(), ticket_response_sla_met = $response_met_set WHERE ticket_id = $ticket_id");
+}
+
+// Judge the resolution SLA when a ticket is resolved (or closed without being
+// resolved, which also stops the clock). No-op for tickets without a
+// resolution target.
+function setTicketResolutionSlaMet($ticket_id)
+{
+ global $mysqli;
+
+ $ticket_id = intval($ticket_id);
+
+ $sql = mysqli_query($mysqli, "SELECT ticket_resolution_due_at, ticket_resolved_at FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
+ if (!$sql || !mysqli_num_rows($sql)) {
+ return;
+ }
+ $row = mysqli_fetch_assoc($sql);
+
+ if (empty($row['ticket_resolution_due_at'])) {
+ return;
+ }
+
+ $ended_at = !empty($row['ticket_resolved_at']) ? strtotime($row['ticket_resolved_at']) : time();
+ $resolution_met = $ended_at <= strtotime($row['ticket_resolution_due_at']) ? 1 : 0;
+
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_met = $resolution_met WHERE ticket_id = $ticket_id");
+}
+
+// A reopened ticket goes back on the resolution clock (original due date - no
+// pause/extend logic yet, that arrives with SLA pausing)
+function resetTicketResolutionSla($ticket_id)
+{
+ global $mysqli;
+
+ $ticket_id = intval($ticket_id);
+
+ mysqli_query($mysqli, "UPDATE tickets SET ticket_resolution_sla_met = NULL, ticket_resolution_sla_alert_stage = 0 WHERE ticket_id = $ticket_id");
+}
|