From 7c9ebedd464b317462750a14c0d8669fa36c5194 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:17:44 -0400 Subject: [PATCH 1/3] feat: add full automation system with conditions, actions, cron, CLI, admin UI, and REST API Add automations table (escalated_automations) with name, conditions/actions JSON fields, active flag, position ordering, and last_run_at tracking. Implement AutomationRunner service matching Laravel implementation with conditions (hours_since_created/updated/assigned, status, priority, assigned/unassigned, ticket_type, subject_contains) and actions (change_status, assign, add_tag, change_priority, add_note, set_ticket_type). Register WP-Cron hook (every 5 minutes), WP-CLI command (wp escalated run-automations), admin page under Escalated menu, and REST API CRUD endpoints at escalated/v1/automations. --- includes/Admin/class-admin-automations.php | 118 +++++++ includes/Admin/class-admin-menu.php | 1 + includes/Api/class-api-bootstrap.php | 1 + includes/Api/class-automation-controller.php | 273 ++++++++++++++++ includes/Cli/AutomationCommand.php | 45 +++ includes/Cron/class-automation-check.php | 13 + includes/Models/Automation.php | 152 +++++++++ includes/Services/AutomationRunner.php | 322 +++++++++++++++++++ includes/class-activator.php | 20 ++ includes/class-deactivator.php | 1 + includes/class-escalated.php | 3 + templates/admin/automations.php | 220 +++++++++++++ 12 files changed, 1169 insertions(+) create mode 100644 includes/Admin/class-admin-automations.php create mode 100644 includes/Api/class-automation-controller.php create mode 100644 includes/Cli/AutomationCommand.php create mode 100644 includes/Cron/class-automation-check.php create mode 100644 includes/Models/Automation.php create mode 100644 includes/Services/AutomationRunner.php create mode 100644 templates/admin/automations.php diff --git a/includes/Admin/class-admin-automations.php b/includes/Admin/class-admin-automations.php new file mode 100644 index 0000000..7beca03 --- /dev/null +++ b/includes/Admin/class-admin-automations.php @@ -0,0 +1,118 @@ +parse_json_field( $_POST['conditions'] ?? '[]' ); + $actions = $this->parse_json_field( $_POST['actions_json'] ?? '[]' ); + + $data = [ + 'name' => sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) ), + 'conditions' => $conditions, + 'actions' => $actions, + 'position' => absint( $_POST['position'] ?? 0 ), + 'active' => isset( $_POST['active'] ) ? 1 : 0, + ]; + + $result = Automation::create( $data ); + if ( $result ) { + $redirect = add_query_arg( 'message', 'created', $redirect ); + } else { + $redirect = add_query_arg( 'message', 'error', $redirect ); + } + break; + + case 'update': + $id = absint( $_POST['id'] ?? 0 ); + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_escalated_nonce'] ?? '' ) ), 'escalated_automation_update_' . $id ) ) { + wp_die( esc_html__( 'Security check failed.', 'escalated' ) ); + } + + $conditions = $this->parse_json_field( $_POST['conditions'] ?? '[]' ); + $actions = $this->parse_json_field( $_POST['actions_json'] ?? '[]' ); + + $data = [ + 'name' => sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) ), + 'conditions' => $conditions, + 'actions' => $actions, + 'position' => absint( $_POST['position'] ?? 0 ), + 'active' => isset( $_POST['active'] ) ? 1 : 0, + ]; + + Automation::update( $id, $data ); + $redirect = add_query_arg( 'message', 'updated', $redirect ); + break; + + case 'delete': + $id = absint( $_POST['id'] ?? 0 ); + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_escalated_nonce'] ?? '' ) ), 'escalated_automation_delete_' . $id ) ) { + wp_die( esc_html__( 'Security check failed.', 'escalated' ) ); + } + + Automation::delete( $id ); + $redirect = add_query_arg( 'message', 'deleted', $redirect ); + break; + } + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Parse a JSON string field from POST data. + * + * @param string|array $value Raw POST value. + * @return array + */ + private function parse_json_field( $value ): array { + if ( is_array( $value ) ) { + return array_map( 'sanitize_text_field', $value ); + } + + $decoded = json_decode( wp_unslash( $value ), true ); + return is_array( $decoded ) ? $decoded : []; + } +} diff --git a/includes/Admin/class-admin-menu.php b/includes/Admin/class-admin-menu.php index 6a4bdfb..80ddd95 100644 --- a/includes/Admin/class-admin-menu.php +++ b/includes/Admin/class-admin-menu.php @@ -22,6 +22,7 @@ public function add_menus(): void { add_submenu_page( 'escalated', __( 'Tickets', 'escalated' ), __( 'Tickets', 'escalated' ), 'escalated_view_tickets', 'escalated', [ new Admin_Tickets(), 'render_list' ] ); add_submenu_page( 'escalated', __( 'Departments', 'escalated' ), __( 'Departments', 'escalated' ), 'escalated_manage_departments', 'escalated-departments', [ new Admin_Departments(), 'render' ] ); add_submenu_page( 'escalated', __( 'SLA Policies', 'escalated' ), __( 'SLA Policies', 'escalated' ), 'escalated_manage_sla', 'escalated-sla-policies', [ new Admin_Sla_Policies(), 'render' ] ); + add_submenu_page( 'escalated', __( 'Automations', 'escalated' ), __( 'Automations', 'escalated' ), 'escalated_automation_manage', 'escalated-automations', [ new Admin_Automations(), 'render' ] ); add_submenu_page( 'escalated', __( 'Escalation Rules', 'escalated' ), __( 'Escalation Rules', 'escalated' ), 'escalated_manage_escalation_rules', 'escalated-escalation-rules', [ new Admin_Escalation_Rules(), 'render' ] ); add_submenu_page( 'escalated', __( 'Tags', 'escalated' ), __( 'Tags', 'escalated' ), 'escalated_manage_tags', 'escalated-tags', [ new Admin_Tags(), 'render' ] ); add_submenu_page( 'escalated', __( 'Canned Responses', 'escalated' ), __( 'Canned Responses', 'escalated' ), 'escalated_use_canned_responses', 'escalated-canned-responses', [ new Admin_Canned_Responses(), 'render' ] ); diff --git a/includes/Api/class-api-bootstrap.php b/includes/Api/class-api-bootstrap.php index 6733418..b0abd17 100644 --- a/includes/Api/class-api-bootstrap.php +++ b/includes/Api/class-api-bootstrap.php @@ -32,6 +32,7 @@ public function register_routes(): void { new Canned_Response_Controller(), new Macro_Controller(), new Agent_Controller(), + new Automation_Controller(), new Dashboard_Controller(), new Api_Token_Controller(), ]; diff --git a/includes/Api/class-automation-controller.php b/includes/Api/class-automation-controller.php new file mode 100644 index 0000000..51e0f69 --- /dev/null +++ b/includes/Api/class-automation-controller.php @@ -0,0 +1,273 @@ +namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'token_permissions_check' ], + 'args' => [ + 'active' => [ + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create_item' ], + 'permission_callback' => [ $this, 'token_permissions_check' ], + 'args' => [ + 'name' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'conditions' => [ + 'required' => true, + 'type' => 'array', + ], + 'actions' => [ + 'required' => true, + 'type' => 'array', + ], + ], + ], + ] + ); + + // Get / Update / Delete + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_item' ], + 'permission_callback' => [ $this, 'token_permissions_check' ], + ], + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'update_item' ], + 'permission_callback' => [ $this, 'token_permissions_check' ], + ], + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'delete_item' ], + 'permission_callback' => [ $this, 'token_permissions_check' ], + ], + ] + ); + } + + /** + * List automations. + * + * @param WP_REST_Request $request + * @return WP_REST_Response|\WP_Error + */ + public function get_items( $request ) { + $user_id = $this->check_token_permission( $request, 'automations:read' ); + + if ( null === $user_id ) { + return $this->error( 'escalated_unauthorized', __( 'Unauthorized.', 'escalated' ), 401 ); + } + + $filters = []; + if ( $request->has_param( 'active' ) ) { + $filters['active'] = (int) $request->get_param( 'active' ); + } + + $automations = Automation::all( $filters ); + + return $this->success( [ + 'automations' => array_map( [ $this, 'format_automation' ], $automations ), + ] ); + } + + /** + * Get a single automation. + * + * @param WP_REST_Request $request + * @return WP_REST_Response|\WP_Error + */ + public function get_item( $request ) { + $user_id = $this->check_token_permission( $request, 'automations:read' ); + + if ( null === $user_id ) { + return $this->error( 'escalated_unauthorized', __( 'Unauthorized.', 'escalated' ), 401 ); + } + + $automation = Automation::find( (int) $request->get_param( 'id' ) ); + + if ( ! $automation ) { + return $this->error( 'escalated_not_found', __( 'Automation not found.', 'escalated' ), 404 ); + } + + return $this->success( $this->format_automation( $automation ) ); + } + + /** + * Create a new automation. + * + * @param WP_REST_Request $request + * @return WP_REST_Response|\WP_Error + */ + public function create_item( $request ) { + $user_id = $this->check_token_permission( $request, 'automations:write' ); + + if ( null === $user_id ) { + return $this->error( 'escalated_unauthorized', __( 'Unauthorized.', 'escalated' ), 401 ); + } + + $data = [ + 'name' => sanitize_text_field( $request->get_param( 'name' ) ), + 'conditions' => $request->get_param( 'conditions' ), + 'actions' => $request->get_param( 'actions' ), + 'active' => $request->has_param( 'active' ) ? (int) $request->get_param( 'active' ) : 1, + 'position' => $request->has_param( 'position' ) ? absint( $request->get_param( 'position' ) ) : 0, + ]; + + $id = Automation::create( $data ); + + if ( ! $id ) { + return $this->error( 'escalated_create_failed', __( 'Failed to create automation.', 'escalated' ), 500 ); + } + + $automation = Automation::find( $id ); + + return $this->success( $this->format_automation( $automation ), 201 ); + } + + /** + * Update an automation. + * + * @param WP_REST_Request $request + * @return WP_REST_Response|\WP_Error + */ + public function update_item( $request ) { + $user_id = $this->check_token_permission( $request, 'automations:write' ); + + if ( null === $user_id ) { + return $this->error( 'escalated_unauthorized', __( 'Unauthorized.', 'escalated' ), 401 ); + } + + $id = (int) $request->get_param( 'id' ); + $automation = Automation::find( $id ); + + if ( ! $automation ) { + return $this->error( 'escalated_not_found', __( 'Automation not found.', 'escalated' ), 404 ); + } + + $data = []; + + if ( $request->has_param( 'name' ) ) { + $data['name'] = sanitize_text_field( $request->get_param( 'name' ) ); + } + if ( $request->has_param( 'conditions' ) ) { + $data['conditions'] = $request->get_param( 'conditions' ); + } + if ( $request->has_param( 'actions' ) ) { + $data['actions'] = $request->get_param( 'actions' ); + } + if ( $request->has_param( 'active' ) ) { + $data['active'] = (int) $request->get_param( 'active' ); + } + if ( $request->has_param( 'position' ) ) { + $data['position'] = absint( $request->get_param( 'position' ) ); + } + + Automation::update( $id, $data ); + + $automation = Automation::find( $id ); + + return $this->success( $this->format_automation( $automation ) ); + } + + /** + * Delete an automation. + * + * @param WP_REST_Request $request + * @return WP_REST_Response|\WP_Error + */ + public function delete_item( $request ) { + $user_id = $this->check_token_permission( $request, 'automations:write' ); + + if ( null === $user_id ) { + return $this->error( 'escalated_unauthorized', __( 'Unauthorized.', 'escalated' ), 401 ); + } + + $id = (int) $request->get_param( 'id' ); + $automation = Automation::find( $id ); + + if ( ! $automation ) { + return $this->error( 'escalated_not_found', __( 'Automation not found.', 'escalated' ), 404 ); + } + + Automation::delete( $id ); + + return $this->success( [ 'deleted' => true ] ); + } + + /** + * Format an automation object for API response. + * + * @param object $automation + * @return array + */ + protected function format_automation( object $automation ): array { + $conditions = $automation->conditions; + if ( is_string( $conditions ) ) { + $decoded = json_decode( $conditions, true ); + $conditions = is_array( $decoded ) ? $decoded : []; + } + + $actions = $automation->actions; + if ( is_string( $actions ) ) { + $decoded = json_decode( $actions, true ); + $actions = is_array( $decoded ) ? $decoded : []; + } + + return [ + 'id' => (int) $automation->id, + 'name' => $automation->name, + 'conditions' => $conditions, + 'actions' => $actions, + 'active' => (bool) $automation->active, + 'position' => (int) $automation->position, + 'last_run_at' => $automation->last_run_at, + 'created_at' => $automation->created_at, + 'updated_at' => $automation->updated_at, + ]; + } +} diff --git a/includes/Cli/AutomationCommand.php b/includes/Cli/AutomationCommand.php new file mode 100644 index 0000000..10861b3 --- /dev/null +++ b/includes/Cli/AutomationCommand.php @@ -0,0 +1,45 @@ +run(); + + if ( $affected > 0 ) { + WP_CLI::success( sprintf( '%d ticket(s) affected by automations.', $affected ) ); + } else { + WP_CLI::log( 'No tickets matched any automation conditions.' ); + } + } + + /** + * Register WP-CLI commands. + */ + public static function register(): void { + if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { + return; + } + + WP_CLI::add_command( 'escalated run-automations', [ new self(), 'run_automations' ] ); + } +} diff --git a/includes/Cron/class-automation-check.php b/includes/Cron/class-automation-check.php new file mode 100644 index 0000000..513b6c2 --- /dev/null +++ b/includes/Cron/class-automation-check.php @@ -0,0 +1,13 @@ +run(); + } +} diff --git a/includes/Models/Automation.php b/includes/Models/Automation.php new file mode 100644 index 0000000..d8448ac --- /dev/null +++ b/includes/Models/Automation.php @@ -0,0 +1,152 @@ +get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id) + ); + } + + /** + * Create a new automation. + * + * @param array $data + * @return int|false Inserted ID or false on failure. + */ + public static function create(array $data) { + global $wpdb; + $table = static::table(); + $now = current_time('mysql'); + + if ( isset($data['conditions']) && is_array($data['conditions'])) { + $data['conditions'] = wp_json_encode($data['conditions']); + } + if ( isset($data['actions']) && is_array($data['actions'])) { + $data['actions'] = wp_json_encode($data['actions']); + } + + $data['created_at'] = $now; + $data['updated_at'] = $now; + + $result = $wpdb->insert($table, $data); + + return $result !== false ? $wpdb->insert_id : false; + } + + /** + * Update an automation. + * + * @param int $id + * @param array $data + * @return bool + */ + public static function update($id, array $data) { + global $wpdb; + $table = static::table(); + + if ( isset($data['conditions']) && is_array($data['conditions'])) { + $data['conditions'] = wp_json_encode($data['conditions']); + } + if ( isset($data['actions']) && is_array($data['actions'])) { + $data['actions'] = wp_json_encode($data['actions']); + } + + $data['updated_at'] = current_time('mysql'); + + return $wpdb->update($table, $data, ['id' => $id]) !== false; + } + + /** + * Delete an automation. + * + * @param int $id + * @return bool + */ + public static function delete($id) { + global $wpdb; + $table = static::table(); + + return $wpdb->delete($table, ['id' => $id]) !== false; + } + + /** + * Get all automations with optional filters. + * + * @param array $filters + * @return array + */ + public static function all(array $filters = []) { + global $wpdb; + $table = static::table(); + $where = ['1=1']; + $values = []; + + if ( isset($filters['active'])) { + $where[] = 'active = %d'; + $values[] = (int) $filters['active']; + } + + $where_clause = implode(' AND ', $where); + $sql = "SELECT * FROM {$table} WHERE {$where_clause} ORDER BY position ASC"; + + if ( ! empty($values)) { + $sql = $wpdb->prepare($sql, $values); + } + + return $wpdb->get_results($sql) ?: []; + } + + /** + * Get all active automations ordered by position. + * + * @return array + */ + public static function active() { + global $wpdb; + $table = static::table(); + + return $wpdb->get_results( + "SELECT * FROM {$table} WHERE active = 1 ORDER BY position ASC" + ) ?: []; + } + + /** + * Update the last_run_at timestamp. + * + * @param int $id + * @return bool + */ + public static function touch_last_run($id) { + global $wpdb; + $table = static::table(); + + return $wpdb->update( + $table, + ['last_run_at' => current_time('mysql')], + ['id' => $id] + ) !== false; + } +} diff --git a/includes/Services/AutomationRunner.php b/includes/Services/AutomationRunner.php new file mode 100644 index 0000000..6edef37 --- /dev/null +++ b/includes/Services/AutomationRunner.php @@ -0,0 +1,322 @@ +find_matching_tickets( $automation ); + + foreach ( $tickets as $ticket ) { + $this->execute_actions( $automation, $ticket ); + $affected++; + } + + Automation::touch_last_run( (int) $automation->id ); + } + + return $affected; + } + + /** + * Find open tickets matching an automation's conditions. + * + * Supported condition fields: + * - hours_since_created + * - hours_since_updated + * - hours_since_assigned + * - status + * - priority + * - assigned (value: "assigned" or "unassigned") + * - ticket_type + * - subject_contains + * + * @param object $automation Automation row with JSON conditions field. + * @return array Array of ticket objects. + */ + protected function find_matching_tickets( object $automation ): array { + global $wpdb; + + $table = Ticket::table(); + $conditions = json_decode( $automation->conditions, true ); + + if ( empty( $conditions ) || ! is_array( $conditions ) ) { + return []; + } + + $where = [ 't.deleted_at IS NULL', 't.' . Ticket::scope_open() ]; + $values = []; + $now = current_time( 'mysql' ); + + foreach ( $conditions as $condition ) { + $field = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? '>'; + $value = $condition['value'] ?? ''; + + switch ( $field ) { + case 'hours_since_created': + $threshold = gmdate( 'Y-m-d H:i:s', strtotime( $now ) - ( absint( $value ) * 3600 ) ); + $sql_op = $this->resolve_operator( $operator ); + $where[] = "t.created_at {$sql_op} %s"; + $values[] = $threshold; + break; + + case 'hours_since_updated': + $threshold = gmdate( 'Y-m-d H:i:s', strtotime( $now ) - ( absint( $value ) * 3600 ) ); + $sql_op = $this->resolve_operator( $operator ); + $where[] = "t.updated_at {$sql_op} %s"; + $values[] = $threshold; + break; + + case 'hours_since_assigned': + $threshold = gmdate( 'Y-m-d H:i:s', strtotime( $now ) - ( absint( $value ) * 3600 ) ); + $sql_op = $this->resolve_operator( $operator ); + $where[] = 't.assigned_to IS NOT NULL'; + $where[] = "t.updated_at {$sql_op} %s"; + $values[] = $threshold; + break; + + case 'status': + $where[] = 't.status = %s'; + $values[] = sanitize_text_field( $value ); + break; + + case 'priority': + $where[] = 't.priority = %s'; + $values[] = sanitize_text_field( $value ); + break; + + case 'assigned': + if ( $value === 'unassigned' ) { + $where[] = 't.assigned_to IS NULL'; + } elseif ( $value === 'assigned' ) { + $where[] = 't.assigned_to IS NOT NULL'; + } + break; + + case 'ticket_type': + $where[] = 't.ticket_type = %s'; + $values[] = sanitize_text_field( $value ); + break; + + case 'subject_contains': + $like = '%' . $wpdb->esc_like( sanitize_text_field( $value ) ) . '%'; + $where[] = 't.subject LIKE %s'; + $values[] = $like; + break; + } + } + + $where_clause = implode( ' AND ', $where ); + $sql = "SELECT t.* FROM {$table} AS t WHERE {$where_clause}"; + + if ( ! empty( $values ) ) { + $sql = $wpdb->prepare( $sql, $values ); + } + + return $wpdb->get_results( $sql ) ?: []; + } + + /** + * Execute an automation's actions on a ticket. + * + * Supported action types: + * - change_status + * - assign + * - add_tag + * - change_priority + * - add_note + * - set_ticket_type + * + * @param object $automation The automation row. + * @param object $ticket The ticket object. + */ + protected function execute_actions( object $automation, object $ticket ): void { + $actions = json_decode( $automation->actions, true ); + + if ( empty( $actions ) || ! is_array( $actions ) ) { + return; + } + + $ticket_id = (int) $ticket->id; + $automation_id = (int) $automation->id; + + foreach ( $actions as $action ) { + $type = $action['type'] ?? ''; + $value = $action['value'] ?? ''; + + try { + switch ( $type ) { + case 'change_status': + if ( ! empty( $value ) ) { + $ticket_service = new TicketService(); + $ticket_service->change_status( $ticket_id, sanitize_text_field( $value ) ); + } + break; + + case 'assign': + if ( ! empty( $value ) ) { + $assignment_service = new AssignmentService(); + $assignment_service->assign( $ticket_id, absint( $value ) ); + } + break; + + case 'add_tag': + if ( ! empty( $value ) ) { + $this->add_tag_to_ticket( $ticket_id, sanitize_text_field( $value ) ); + } + break; + + case 'change_priority': + if ( ! empty( $value ) ) { + $ticket_service = new TicketService(); + $ticket_service->change_priority( $ticket_id, sanitize_text_field( $value ) ); + } + break; + + case 'add_note': + if ( ! empty( $value ) ) { + Reply::create( [ + 'ticket_id' => $ticket_id, + 'author_id' => null, + 'body' => sanitize_textarea_field( $value ), + 'is_internal_note' => 1, + 'is_pinned' => 0, + 'type' => 'note', + 'metadata' => wp_json_encode( [ + 'system_note' => true, + 'automation_id' => $automation_id, + ] ), + ] ); + } + break; + + case 'set_ticket_type': + if ( ! empty( $value ) && in_array( $value, self::TICKET_TYPES, true ) ) { + Ticket::update( $ticket_id, [ + 'ticket_type' => sanitize_text_field( $value ), + ] ); + } + break; + } + } catch ( \Throwable $e ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( sprintf( + 'Escalated automation action failed: automation=%d ticket=%d action=%s error=%s', + $automation_id, + $ticket_id, + $type, + $e->getMessage() + ) ); + } + } + } + + $this->log_activity( $ticket_id, null, 'automation_executed', [ + 'automation_id' => $automation_id, + 'automation_name' => $automation->name, + ] ); + + do_action( 'escalated_automation_executed', $ticket, $automation, $actions ); + } + + /** + * Add a tag to a ticket by tag name. + * + * @param int $ticket_id Ticket ID. + * @param string $tag_name Tag name to look up. + */ + protected function add_tag_to_ticket( int $ticket_id, string $tag_name ): void { + global $wpdb; + + $tag_table = Tag::table(); + $pivot_table = Tag::pivot_table(); + + $tag = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$tag_table} WHERE name = %s", $tag_name ) + ); + + if ( ! $tag ) { + return; + } + + // Only insert if not already tagged. + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$pivot_table} WHERE ticket_id = %d AND tag_id = %d", + $ticket_id, + $tag->id + ) + ); + + if ( ! $exists ) { + $wpdb->insert( $pivot_table, [ + 'ticket_id' => $ticket_id, + 'tag_id' => (int) $tag->id, + ] ); + } + } + + /** + * Resolve a condition operator to a SQL comparison operator. + * + * For hours_since fields, > hours means < datetime (older). + * + * @param string $operator Condition operator. + * @return string SQL operator. + */ + protected function resolve_operator( string $operator ): string { + return match ( $operator ) { + '>' => '<', + '>=' => '<=', + '<' => '>', + '<=' => '>=', + '=' => '=', + default => '<', + }; + } + + /** + * Log a ticket activity entry. + * + * @param int $ticket_id Ticket ID. + * @param int|null $causer_id User who caused the activity. + * @param string $type Activity type. + * @param array $properties Additional properties to store as JSON. + */ + protected function log_activity( int $ticket_id, ?int $causer_id, string $type, array $properties = [] ): void { + TicketActivity::create( [ + 'ticket_id' => $ticket_id, + 'causer_id' => $causer_id, + 'type' => $type, + 'properties' => ! empty( $properties ) ? wp_json_encode( $properties ) : null, + 'created_at' => current_time( 'mysql' ), + ] ); + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index c2bf62b..97ccce2 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -335,6 +335,22 @@ private static function create_tables(): void { PRIMARY KEY (role_id, user_id) ) $charset_collate;"; dbDelta( $sql ); + + // 22. escalated_automations + $sql = "CREATE TABLE {$prefix}automations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + conditions LONGTEXT NOT NULL, + actions LONGTEXT NOT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + position INT UNSIGNED NOT NULL DEFAULT 0, + last_run_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY active (active) + ) $charset_collate;"; + dbDelta( $sql ); } /** @@ -711,6 +727,10 @@ private static function schedule_cron_events(): void { if ( ! wp_next_scheduled( 'escalated_purge_activities' ) ) { wp_schedule_event( time(), 'weekly', 'escalated_purge_activities' ); } + + if ( ! wp_next_scheduled( 'escalated_run_automations' ) ) { + wp_schedule_event( time(), 'escalated_every_five_minutes', 'escalated_run_automations' ); + } } /** diff --git a/includes/class-deactivator.php b/includes/class-deactivator.php index fa47849..8ef821a 100644 --- a/includes/class-deactivator.php +++ b/includes/class-deactivator.php @@ -8,5 +8,6 @@ public static function deactivate(): void { wp_clear_scheduled_hook( 'escalated_evaluate_escalations' ); wp_clear_scheduled_hook( 'escalated_auto_close' ); wp_clear_scheduled_hook( 'escalated_purge_activities' ); + wp_clear_scheduled_hook( 'escalated_run_automations' ); } } diff --git a/includes/class-escalated.php b/includes/class-escalated.php index 9696529..e1d2bb8 100644 --- a/includes/class-escalated.php +++ b/includes/class-escalated.php @@ -29,8 +29,11 @@ public function boot(): void { ( new Mail\Inbound_Controller() )->register(); ( new Cron\Sla_Check() )->register(); ( new Cron\Escalation_Check() )->register(); + ( new Cron\Automation_Check() )->register(); ( new Cron\Auto_Close() )->register(); ( new Cron\Activity_Purge() )->register(); + + Cli\AutomationCommand::register(); } public static function table( string $name ): string { diff --git a/templates/admin/automations.php b/templates/admin/automations.php new file mode 100644 index 0000000..932192b --- /dev/null +++ b/templates/admin/automations.php @@ -0,0 +1,220 @@ +conditions ?? '[]', true ) ?: []; + $edit_actions = json_decode( $edit_item->actions ?? '[]', true ) ?: []; +} + +$condition_fields = [ + 'hours_since_created' => __( 'Hours Since Created', 'escalated' ), + 'hours_since_updated' => __( 'Hours Since Updated', 'escalated' ), + 'hours_since_assigned' => __( 'Hours Since Assigned', 'escalated' ), + 'status' => __( 'Status', 'escalated' ), + 'priority' => __( 'Priority', 'escalated' ), + 'assigned' => __( 'Assigned / Unassigned', 'escalated' ), + 'ticket_type' => __( 'Ticket Type', 'escalated' ), + 'subject_contains' => __( 'Subject Contains', 'escalated' ), +]; + +$action_types = [ + 'change_status' => __( 'Change Status', 'escalated' ), + 'assign' => __( 'Assign to Agent', 'escalated' ), + 'add_tag' => __( 'Add Tag', 'escalated' ), + 'change_priority' => __( 'Change Priority', 'escalated' ), + 'add_note' => __( 'Add Internal Note', 'escalated' ), + 'set_ticket_type' => __( 'Set Ticket Type', 'escalated' ), +]; +?> +
+

+
+ + +
+

+ __( 'Automation created successfully.', 'escalated' ), + 'updated' => __( 'Automation updated successfully.', 'escalated' ), + 'deleted' => __( 'Automation deleted successfully.', 'escalated' ), + 'error' => __( 'An error occurred. Please try again.', 'escalated' ), + ]; + echo esc_html( $messages[ $message ] ?? __( 'Action completed.', 'escalated' ) ); + ?> +

+
+ + + + + + + + + + + + + + + + + + + + + + + conditions ?? '[]', true ) ?: []; + $acts = json_decode( $automation->actions ?? '[]', true ) ?: []; + ?> + + + + + + + + + + + + +
position ?? 0 ); ?>name ); ?> + active ) : ?> + + + + + + last_run_at ? esc_html( $automation->last_run_at ) : '' . esc_html__( 'Never', 'escalated' ) . ''; ?> + + + + +
+ + + id, '_escalated_nonce' ); ?> + +
+
+ + +
+

+ +

+ +
+ + + + id, '_escalated_nonce' ); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + +

+
+
+
From 4df61435f7b8bbfddb81e4f9a8643e81d6aefaf3 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:52:51 -0400 Subject: [PATCH 2/3] feat: add SSO service with SAML and JWT validation --- includes/Services/SsoService.php | 248 +++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 includes/Services/SsoService.php diff --git a/includes/Services/SsoService.php b/includes/Services/SsoService.php new file mode 100644 index 0000000..148ffd7 --- /dev/null +++ b/includes/Services/SsoService.php @@ -0,0 +1,248 @@ + 'none', + 'sso_entity_id' => '', + 'sso_url' => '', + 'sso_certificate' => '', + 'sso_attr_email' => 'email', + 'sso_attr_name' => 'name', + 'sso_attr_role' => 'role', + 'sso_jwt_secret' => '', + 'sso_jwt_algorithm' => 'HS256', + ]; + + /** + * Get the current SSO configuration. + */ + public function getConfig() { + $config = []; + foreach ($this->configKeys as $key => $default) { + $config[$key] = Setting::get($key, $default); + } + return $config; + } + + /** + * Save SSO configuration. + */ + public function saveConfig($data) { + $allowed = array_keys($this->configKeys); + foreach ($data as $key => $value) { + if (in_array($key, $allowed, true)) { + Setting::set($key, (string) $value); + } + } + } + + /** + * Check if SSO is enabled. + */ + public function isEnabled() { + return $this->getProvider() !== 'none'; + } + + /** + * Get the active SSO provider type. + */ + public function getProvider() { + return Setting::get('sso_provider', 'none'); + } + + /** + * Validate a base64-encoded SAML response and extract user attributes. + * + * @param string $samlResponse + * @return array Array with 'email', 'name', 'role', 'attributes' + * @throws \RuntimeException + */ + public function validateSamlAssertion($samlResponse) { + $config = $this->getConfig(); + + $xml = base64_decode($samlResponse, true); + if ($xml === false) { + throw new \RuntimeException('Invalid SAML response: base64 decode failed.'); + } + + $doc = new \DOMDocument(); + $prevErrors = libxml_use_internal_errors(true); + $loaded = $doc->loadXML($xml); + libxml_use_internal_errors($prevErrors); + if (!$loaded) { + throw new \RuntimeException('Invalid SAML response: malformed XML.'); + } + + $xpath = new \DOMXPath($doc); + $xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion'); + $xpath->registerNamespace('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'); + + // Check issuer + $entityId = trim($config['sso_entity_id']); + if ($entityId !== '') { + $issuerNodes = $xpath->query('//saml:Issuer'); + if ($issuerNodes->length === 0) { + throw new \RuntimeException('SAML assertion missing Issuer element.'); + } + $issuer = trim($issuerNodes->item(0)->textContent); + if ($issuer !== $entityId) { + throw new \RuntimeException("SAML Issuer mismatch: expected '{$entityId}', got '{$issuer}'."); + } + } + + // Validate conditions + $conditionNodes = $xpath->query('//saml:Conditions'); + if ($conditionNodes->length > 0) { + $conditions = $conditionNodes->item(0); + $now = time(); + $skew = 120; + + $notBefore = $conditions->getAttribute('NotBefore'); + if ($notBefore !== '' && strtotime($notBefore) > ($now + $skew)) { + throw new \RuntimeException('SAML assertion is not yet valid.'); + } + + $notOnOrAfter = $conditions->getAttribute('NotOnOrAfter'); + if ($notOnOrAfter !== '' && strtotime($notOnOrAfter) < ($now - $skew)) { + throw new \RuntimeException('SAML assertion has expired.'); + } + } + + // Extract attributes + $attrEmail = $config['sso_attr_email']; + $attrName = $config['sso_attr_name']; + $attrRole = $config['sso_attr_role']; + + $attributes = []; + $attrNodes = $xpath->query('//saml:AttributeStatement/saml:Attribute'); + foreach ($attrNodes as $attr) { + $name = $attr->getAttribute('Name'); + $valueNodes = $xpath->query('saml:AttributeValue', $attr); + if ($valueNodes->length > 0) { + $attributes[$name] = trim($valueNodes->item(0)->textContent); + } + } + + $email = $attributes[$attrEmail] ?? null; + if (!$email) { + $nameIdNodes = $xpath->query('//saml:Subject/saml:NameID'); + if ($nameIdNodes->length > 0) { + $email = trim($nameIdNodes->item(0)->textContent); + } + } + + if (!$email) { + throw new \RuntimeException('SAML assertion missing email attribute.'); + } + + return [ + 'email' => $email, + 'name' => $attributes[$attrName] ?? '', + 'role' => $attributes[$attrRole] ?? '', + 'attributes' => $attributes, + ]; + } + + /** + * Validate a JWT token and extract user attributes. + * + * @param string $token + * @return array Array with 'email', 'name', 'role', 'claims' + * @throws \RuntimeException + */ + public function validateJwtToken($token) { + $config = $this->getConfig(); + + $parts = explode('.', $token); + if (count($parts) !== 3) { + throw new \RuntimeException('Invalid JWT: expected 3 segments.'); + } + + [$headerB64, $payloadB64, $signatureB64] = $parts; + + $header = json_decode($this->base64UrlDecode($headerB64), true); + if (!$header || !isset($header['alg'])) { + throw new \RuntimeException('Invalid JWT: malformed header.'); + } + + $payload = json_decode($this->base64UrlDecode($payloadB64), true); + if (!$payload) { + throw new \RuntimeException('Invalid JWT: malformed payload.'); + } + + $secret = $config['sso_jwt_secret']; + $algorithm = $config['sso_jwt_algorithm'] ?: 'HS256'; + + if ($secret === '') { + throw new \RuntimeException('JWT secret is not configured.'); + } + + $signature = $this->base64UrlDecode($signatureB64); + $signingInput = $headerB64 . '.' . $payloadB64; + + if (!$this->verifyJwtSignature($signingInput, $signature, $secret, $algorithm)) { + throw new \RuntimeException('Invalid JWT: signature verification failed.'); + } + + $now = time(); + $skew = 60; + + if (isset($payload['exp']) && $payload['exp'] < ($now - $skew)) { + throw new \RuntimeException('JWT has expired.'); + } + + if (isset($payload['nbf']) && $payload['nbf'] > ($now + $skew)) { + throw new \RuntimeException('JWT is not yet valid.'); + } + + $attrEmail = $config['sso_attr_email']; + $attrName = $config['sso_attr_name']; + $attrRole = $config['sso_attr_role']; + + $email = $payload[$attrEmail] ?? $payload['email'] ?? $payload['sub'] ?? null; + if (!$email) { + throw new \RuntimeException('JWT missing email claim.'); + } + + return [ + 'email' => $email, + 'name' => $payload[$attrName] ?? $payload['name'] ?? '', + 'role' => $payload[$attrRole] ?? $payload['role'] ?? '', + 'claims' => $payload, + ]; + } + + /** + * Verify JWT signature. + */ + private function verifyJwtSignature($input, $signature, $secret, $algorithm) { + $algoMap = [ + 'HS256' => 'sha256', + 'HS384' => 'sha384', + 'HS512' => 'sha512', + ]; + + if (isset($algoMap[$algorithm])) { + $expected = hash_hmac($algoMap[$algorithm], $input, $secret, true); + return hash_equals($expected, $signature); + } + + throw new \RuntimeException("Unsupported JWT algorithm: {$algorithm}"); + } + + /** + * Base64url decode. + */ + private function base64UrlDecode($input) { + $remainder = strlen($input) % 4; + if ($remainder) { + $input .= str_repeat('=', 4 - $remainder); + } + return base64_decode(strtr($input, '-_', '+/'), true) ?: ''; + } +} From 41f3560e25b1da834252a683056295eaea4b68f5 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:03:08 -0400 Subject: [PATCH 3/3] test: add SSO/JWT unit tests --- tests/Test_Sso_Service.php | 89 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/Test_Sso_Service.php diff --git a/tests/Test_Sso_Service.php b/tests/Test_Sso_Service.php new file mode 100644 index 0000000..5e3741b --- /dev/null +++ b/tests/Test_Sso_Service.php @@ -0,0 +1,89 @@ +base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $body = $this->base64url_encode(json_encode($payload)); + $signature = $this->base64url_encode( + hash_hmac('sha256', "$header.$body", $secret, true) + ); + return "$header.$body.$signature"; + } + + public function test_jwt_has_three_segments() { + $token = $this->create_jwt(['email' => 'test@example.com'], 'secret'); + $parts = explode('.', $token); + $this->assertCount(3, $parts); + } + + public function test_jwt_payload_round_trips() { + $token = $this->create_jwt([ + 'email' => 'user@test.com', + 'name' => 'Test User', + ], 'secret'); + + $parts = explode('.', $token); + $payload_b64 = $parts[1]; + $remainder = strlen($payload_b64) % 4; + if ($remainder) { + $payload_b64 .= str_repeat('=', 4 - $remainder); + } + $payload = json_decode(base64_decode(strtr($payload_b64, '-_', '+/')), true); + + $this->assertEquals('user@test.com', $payload['email']); + $this->assertEquals('Test User', $payload['name']); + } + + public function test_jwt_signature_verifies_with_correct_secret() { + $secret = 'test-secret'; + $token = $this->create_jwt(['email' => 'a@b.com'], $secret); + [$header, $payload, $sig] = explode('.', $token); + + $expected = $this->base64url_encode( + hash_hmac('sha256', "$header.$payload", $secret, true) + ); + + $this->assertEquals($expected, $sig); + } + + public function test_jwt_signature_fails_with_wrong_secret() { + $token = $this->create_jwt(['email' => 'a@b.com'], 'correct'); + [$header, $payload] = explode('.', $token); + + $wrong_sig = $this->base64url_encode( + hash_hmac('sha256', "$header.$payload", 'wrong', true) + ); + + [, , $original_sig] = explode('.', $token); + $this->assertNotEquals($original_sig, $wrong_sig); + } + + public function test_expired_jwt_detected() { + $payload = ['email' => 'a@b.com', 'exp' => time() - 3600]; + $this->assertTrue($payload['exp'] < time()); + } + + public function test_valid_jwt_not_expired() { + $payload = ['email' => 'a@b.com', 'exp' => time() + 3600]; + $this->assertTrue($payload['exp'] > time()); + } +}