diff --git a/admin/database_updates/2.5.0.php b/admin/database_updates/2.5.0.php new file mode 100644 index 00000000..82792d13 --- /dev/null +++ b/admin/database_updates/2.5.0.php @@ -0,0 +1,59 @@ + + + + diff --git a/admin/modals/sla/sla_add.php b/admin/modals/sla/sla_add.php new file mode 100644 index 00000000..9b9b5a17 --- /dev/null +++ b/admin/modals/sla/sla_add.php @@ -0,0 +1,64 @@ + + + +
+ + + + + +
+ + + + +
+ + + + + +
+ + + + +
+ + + + + + +
+ + 0 ? $resolution_minutes : "NULL"; + + mysqli_query($mysqli, "INSERT INTO slas SET sla_name = '$name', sla_description = '$description', sla_response_minutes = $response_minutes, sla_resolution_minutes = $resolution_minutes_set"); + + logAudit("SLA", "Create", "$session_name created SLA $name"); + + flashAlert("SLA $name created"); + + redirect(); + +} + +if (isset($_POST['edit_sla'])) { + + validateCSRFToken(); + + $sla_id = intval($_POST['sla_id']); + $name = escapeSql($_POST['name']); + $description = escapeSql($_POST['description']); + $response_minutes = intval($_POST['response_minutes']); + $resolution_minutes = intval($_POST['resolution_minutes']); + $resolution_minutes_set = $resolution_minutes > 0 ? $resolution_minutes : "NULL"; + + mysqli_query($mysqli, "UPDATE slas SET sla_name = '$name', sla_description = '$description', sla_response_minutes = $response_minutes, sla_resolution_minutes = $resolution_minutes_set WHERE sla_id = $sla_id"); + + // Re-stamp open tickets on this SLA so their targets follow the new minutes + $restamped = 0; + $sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_sla_id = $sla_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); + while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) { + applyTicketSla($ticket_row['ticket_id'], $sla_id); + $restamped++; + } + + logAudit("SLA", "Edit", "$session_name edited SLA $name"); + + flashAlert("SLA $name updated - targets recalculated on $restamped open ticket(s)"); + + redirect(); + +} + +if (isset($_GET['archive_sla'])) { + + validateCSRFToken(); + + $sla_id = intval($_GET['archive_sla']); + + mysqli_query($mysqli, "UPDATE slas SET sla_archived_at = NOW() WHERE sla_id = $sla_id"); + + // Assignments pointing at an archived SLA resolve to "no SLA" for new + // tickets; existing tickets keep their stamped targets + + logAudit("SLA", "Archive", "$session_name archived SLA ID $sla_id"); + + flashAlert("SLA archived"); + + redirect(); + +} + +if (isset($_GET['unarchive_sla'])) { + + validateCSRFToken(); + + $sla_id = intval($_GET['unarchive_sla']); + + mysqli_query($mysqli, "UPDATE slas SET sla_archived_at = NULL WHERE sla_id = $sla_id"); + + logAudit("SLA", "Unarchive", "$session_name restored SLA ID $sla_id"); + + flashAlert("SLA restored"); + + redirect(); + +} + +if (isset($_POST['edit_sla_settings'])) { + + validateCSRFToken(); + + // Business days arrive as an array of ISO weekday numbers (1 = Mon .. 7 = Sun) + $business_days = []; + if (isset($_POST['business_days']) && is_array($_POST['business_days'])) { + foreach ($_POST['business_days'] as $day) { + $day = intval($day); + if ($day >= 1 && $day <= 7) { + $business_days[] = $day; + } + } + } + $business_days = escapeSql(implode(',', $business_days)); + + $business_hours_start = escapeSql($_POST['business_hours_start']); + $business_hours_end = escapeSql($_POST['business_hours_end']); + $warning_percent = intval($_POST['warning_percent']); + $notification_email = escapeSql($_POST['notification_email']); + + mysqli_query($mysqli, "UPDATE settings SET config_business_days = '$business_days', config_business_hours_start = '$business_hours_start', config_business_hours_end = '$business_hours_end', config_sla_warning_percent = $warning_percent, config_sla_notification_email = '$notification_email' WHERE company_id = 1"); + + // Business hours feed the due date math - re-stamp open SLA tickets + $restamped = 0; + $sql_tickets = mysqli_query($mysqli, "SELECT ticket_id, ticket_sla_id FROM tickets WHERE ticket_sla_id > 0 AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); + while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) { + applyTicketSla($ticket_row['ticket_id'], $ticket_row['ticket_sla_id']); + $restamped++; + } + + logAudit("Settings", "Edit", "$session_name edited SLA / business hours settings"); + + flashAlert("SLA settings updated - targets recalculated on $restamped open ticket(s)"); + + redirect(); + +} + +if (isset($_POST['save_sla_assignments'])) { + + validateCSRFToken(); + + // Global defaults - one select per priority; 0 means no SLA, which for the + // global row is simply no assignment + foreach (['Low', 'Medium', 'High'] as $priority) { + + $field = 'global_sla_' . strtolower($priority); + $sla_id = intval($_POST[$field] ?? 0); + + mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = 0 AND sla_assignment_priority = '$priority'"); + if ($sla_id > 0) { + mysqli_query($mysqli, "INSERT INTO sla_assignments SET sla_assignment_client_id = 0, sla_assignment_priority = '$priority', sla_assignment_sla_id = $sla_id"); + } + } + + // Re-resolve open tickets against the new defaults + $restamped = 0; + $sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); + while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) { + applyTicketSla($ticket_row['ticket_id']); + $restamped++; + } + + logAudit("SLA", "Edit", "$session_name updated default SLA assignments"); + + flashAlert("Default SLA assignments saved - $restamped open ticket(s) re-evaluated"); + + redirect(); + +} + +if (isset($_POST['save_client_sla_assignment'])) { + + validateCSRFToken(); + + $client_id = intval($_POST['client_id']); + + // Per-priority values: 'default' = follow the global default (no row), + // '0' = explicitly no SLA for this client, otherwise an sla_id + foreach (['Low', 'Medium', 'High'] as $priority) { + + $field = 'client_sla_' . strtolower($priority); + $value = $_POST[$field] ?? 'default'; + + mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id AND sla_assignment_priority = '$priority'"); + if ($value !== 'default') { + $sla_id = intval($value); + mysqli_query($mysqli, "INSERT INTO sla_assignments SET sla_assignment_client_id = $client_id, sla_assignment_priority = '$priority', sla_assignment_sla_id = $sla_id"); + } + } + + // Re-resolve this client's open tickets + $restamped = 0; + $sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_client_id = $client_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); + while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) { + applyTicketSla($ticket_row['ticket_id']); + $restamped++; + } + + logAudit("SLA", "Edit", "$session_name updated SLA assignments for client ID $client_id"); + + flashAlert("Client SLA assignments saved - $restamped open ticket(s) re-evaluated"); + + redirect(); + +} + +if (isset($_GET['delete_client_sla_assignments'])) { + + validateCSRFToken(); + + $client_id = intval($_GET['delete_client_sla_assignments']); + + mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id"); + + // Back on the global defaults - re-resolve this client's open tickets + $sql_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_client_id = $client_id AND ticket_closed_at IS NULL AND ticket_archived_at IS NULL"); + while ($ticket_row = mysqli_fetch_assoc($sql_tickets)) { + applyTicketSla($ticket_row['ticket_id']); + } + + logAudit("SLA", "Delete", "$session_name removed SLA overrides for client ID $client_id"); + + flashAlert("Client SLA overrides removed"); + + redirect(); + +} diff --git a/admin/sla.php b/admin/sla.php new file mode 100644 index 00000000..19dba03c --- /dev/null +++ b/admin/sla.php @@ -0,0 +1,274 @@ +Default"; + } + $sla_id = $assignments[$client_id][$priority]; + if ($sla_id == 0) { + return "None"; + } + if (isset($active_slas[$sla_id])) { + return escapeHtml($active_slas[$sla_id]); + } + return "None (archived)"; +} + +?> + +
+
+

SLAs

+
+ +
+
+ +
+ +

SLAs are optional. Tickets only get response/resolution targets when an assignment below matches their client and priority - with nothing assigned, nothing changes.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
NameDescriptionResponseResolutionStatusAction
+ + + + min + +
Archived
+ +
Active
+ +
+ +
+
+
+
+ +
+
+

SLA Assignments

+
+ +
+
+ +
+ +
+ + + Default (all clients) +
+ +
+ + +
+ +
+ +
+
+
+ +
+ + Client overrides +
+ + "> + + + + + + + + + + + + + $override_client_name) { ?> + + + + + + + + + +
ClientLowMediumHighAction
No client overrides - everyone follows the defaults above.
+ + + + + +
+
+
+
+ +
+
+

Business Hours & Notifications

+
+ +
+ +
+ + +
+ +
+ 'Mon', 2 => 'Tue', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', 7 => 'Sun'] as $day_number => $day_name) { ?> +
+ > + +
+ +
+ SLA clocks only run during business hours on these days. Unchecking everything makes SLAs count 24x7. +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Warnings and breach alerts also notify the assigned agent in-app and by email. Requires cron/ticket_sla.php running every minute. + + +
+ +
+
+ + + + +
+ + + + + + + +
+ +$original_sla_name to $sla_name"); + + redirect(); + +} + if (isset($_POST['edit_ticket_contact'])) { validateCSRFToken(); @@ -1091,6 +1154,7 @@ if (isset($_POST['bulk_edit_ticket_priority'])) { // Update ticket & insert reply mysqli_query($mysqli, "UPDATE tickets SET ticket_priority = '$priority' WHERE ticket_id = $ticket_id"); + applyTicketSla($ticket_id); mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$session_name updated the priority from $current_ticket_priority to $priority', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:01:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id"); @@ -1208,10 +1272,11 @@ if (isset($_POST['bulk_merge_tickets'])) { // Update current ticket if (empty($ticket_first_response_at)) { - mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketFirstResponse($ticket_id); } mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number bulk merged into $ticket_prefix$merge_into_ticket_number. Comment: $merge_comment', ticket_reply_time_worked = '00:01:00', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id"); mysqli_query($mysqli, "UPDATE tickets SET ticket_status = '5', ticket_resolved_at = NOW(), ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id") or die(mysqli_error($mysqli)); + setTicketResolutionSlaMet($ticket_id); // Update new parent ticket mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number was bulk merged into this ticket with comment: $merge_comment.

$ticket_subject
$ticket_details', ticket_reply_time_worked = '00:01:00', ticket_reply_type = 'Internal', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $merge_into_ticket_id"); @@ -1286,11 +1351,12 @@ if (isset($_POST['bulk_resolve_tickets'])) { // Mark FR time if required if (empty($ticket_first_response_at)) { - mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketFirstResponse($ticket_id); } // Update ticket & insert reply mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 4, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketResolutionSlaMet($ticket_id); mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$details', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '$ticket_reply_time_worked', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id"); @@ -1426,9 +1492,9 @@ if (isset($_POST['bulk_ticket_reply'])) { $client_uri = ''; } - // Mark FR time if required - if (empty($ticket_first_response_at)) { - mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id"); + // Mark FR time if required - internal notes don't count as a response + if (empty($ticket_first_response_at) && $ticket_reply_type == 'Public') { + setTicketFirstResponse($ticket_id); } // Add reply @@ -1451,6 +1517,7 @@ if (isset($_POST['bulk_ticket_reply'])) { // Resolve the ticket, if set if ($ticket_status == 4) { mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketResolutionSlaMet($ticket_id); // Logging logAudit("Ticket", "Resolved", "$session_name resolved Ticket $ticket_prefix$ticket_number", $client_id, $ticket_id); @@ -1684,6 +1751,7 @@ if (isset($_POST['bulk_add_asset_ticket'])) { mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_category = $category_id, ticket_subject = '$subject_asset_prepended', ticket_details = '$details', ticket_priority = '$priority', ticket_billable = $billable, ticket_status = $ticket_status, ticket_asset_id = $asset_id, ticket_created_by = $session_user_id, ticket_assigned_to = $assigned_to, ticket_url_key = '$url_key', ticket_client_id = $client_id, ticket_project_id = $project_id"); $ticket_id = mysqli_insert_id($mysqli); + applyTicketSla($ticket_id); // Add Tasks if (!empty($_POST['tasks'])) { @@ -1769,6 +1837,7 @@ if (isset($_POST['add_ticket_reply'])) { // Resolve the ticket, if set if ($ticket_status == 4) { mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketResolutionSlaMet($ticket_id); logAudit("Ticket", "Resolved", "$session_name resolved Ticket ticket ID $ticket_id", $client_id, $ticket_id); } @@ -1885,7 +1954,7 @@ if (isset($_POST['add_ticket_reply'])) { // Handle first response if (empty($ticket_first_response_at) && $ticket_reply_type == 'Public') { - mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketFirstResponse($ticket_id); } // Custom action/notif handler @@ -2059,12 +2128,13 @@ if (isset($_POST['merge_ticket'])) { // Update current ticket if (empty($ticket_first_response_at)) { - mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketFirstResponse($ticket_id); } mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number merged into $ticket_prefix$merge_into_ticket_number. Comment: $merge_comment', ticket_reply_time_worked = '00:01:00', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id"); mysqli_query($mysqli, "UPDATE tickets SET ticket_status = '5', ticket_resolved_at = NOW(), ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id") or die(mysqli_error($mysqli)); + setTicketResolutionSlaMet($ticket_id); //Update new parent ticket mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket $ticket_prefix$ticket_number was merged into this ticket with comment: $merge_comment.

$ticket_subject
$ticket_details', ticket_reply_time_worked = '00:01:00', ticket_reply_type = '$ticket_reply_type', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $merge_into_ticket_id"); @@ -2101,6 +2171,7 @@ if (isset($_POST['change_client_ticket'])) { // Update ticket client & contact mysqli_query($mysqli, "UPDATE tickets SET ticket_client_id = $client_id, ticket_contact_id = $contact_id WHERE ticket_id = $ticket_id LIMIT 1"); + applyTicketSla($ticket_id); logAudit("Ticket", "Change", "$session_name changed ticket client", $client_id, $ticket_id); @@ -2134,11 +2205,12 @@ if (isset($_GET['resolve_ticket'])) { // Mark FR if (empty($ticket_first_response_at)) { - mysqli_query($mysqli, "UPDATE tickets SET ticket_first_response_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketFirstResponse($ticket_id); } // Resolve mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 4, ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); + setTicketResolutionSlaMet($ticket_id); logAudit("Ticket", "Resolved", "$session_name resolved ticket $ticket_prefix$ticket_number (ID: $ticket_id)", $client_id, $ticket_id); @@ -2239,6 +2311,7 @@ if (isset($_GET['close_ticket'])) { } mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 5, ticket_closed_at = NOW(), ticket_closed_by = $session_user_id WHERE ticket_id = $ticket_id") or die(mysqli_error($mysqli)); + setTicketResolutionSlaMet($ticket_id); mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = 'Ticket closed.', ticket_reply_type = 'Internal', ticket_reply_time_worked = '00:01:00', ticket_reply_by = $session_user_id, ticket_reply_ticket_id = $ticket_id"); @@ -2339,6 +2412,7 @@ if (isset($_GET['reopen_ticket'])) { } mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id"); + resetTicketResolutionSla($ticket_id); logAudit("Ticket", "Reopened", "$session_name reopened ticket ID $ticket_id", $client_id, $ticket_id); diff --git a/agent/ticket.php b/agent/ticket.php index fe65f891..d422b1bf 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -110,6 +110,18 @@ if (isset($_GET['ticket_id'])) { $ticket_updated_at = escapeHtml($row['ticket_updated_at']); $ticket_updated_at_ago = timeAgo($row['ticket_updated_at']); $ticket_first_response_at = escapeHtml($row['ticket_first_response_at']); + $ticket_sla_id = intval($row['ticket_sla_id']); + $ticket_response_due_at = escapeHtml($row['ticket_response_due_at']); + $ticket_resolution_due_at = escapeHtml($row['ticket_resolution_due_at']); + $ticket_response_sla_met = $row['ticket_response_sla_met']; + $ticket_resolution_sla_met = $row['ticket_resolution_sla_met']; + $ticket_sla_name = "None"; + if ($ticket_sla_id) { + $sla_name_sql = mysqli_query($mysqli, "SELECT sla_name FROM slas WHERE sla_id = $ticket_sla_id"); + if (mysqli_num_rows($sla_name_sql)) { + $ticket_sla_name = escapeHtml(mysqli_fetch_assoc($sla_name_sql)['sla_name']); + } + } $ticket_resolved_at = escapeHtml($row['ticket_resolved_at']); $ticket_resolved_at_ago = timeAgo($row['ticket_resolved_at']); $ticket_resolved_date = date('Y-m-d', strtotime($ticket_resolved_at)); @@ -488,6 +500,16 @@ if (isset($_GET['ticket_id'])) { +
+ SLA: + = 2 && empty($ticket_closed_at)) { ?> + class="ajax-modal" + data-modal-url="modals/ticket/ticket_sla.php?id=" + + > +
+
@@ -878,6 +900,20 @@ if (isset($_GET['ticket_id'])) {
+ + +
+ Respond by: + " : ""; } ?> +
+ + +
+ Resolve by: + " : ""; } ?> +
+ +
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 = "

Never

"; @@ -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"); +}