array( 'label' => __( 'On a schedule', 'ai-eshop-optimizer' ), 'desc' => __( 'Run periodically over the audience (e.g. a weekly digest).', 'ai-eshop-optimizer' ), 'config' => array( 'interval' => array( 'type' => 'select', 'label' => __( 'Every', 'ai-eshop-optimizer' ), 'options' => array( 'daily' => 'day', 'weekly' => 'week', 'monthly' => 'month' ), 'default' => 'weekly' ) ), 'wired' => true, ), 'abandoned_cart' => array( 'label' => __( 'Abandoned cart', 'ai-eshop-optimizer' ), 'desc' => __( 'A product was added to cart but not purchased (registered customers — behavioural add-to-cart signal).', 'ai-eshop-optimizer' ), 'config' => array( 'hours' => array( 'type' => 'number', 'label' => __( 'Idle hours before firing', 'ai-eshop-optimizer' ), 'default' => 4 ) ), 'wired' => true, ), 'abandoned_checkout' => array( 'label' => __( 'Abandoned checkout (cart recovery)', 'ai-eshop-optimizer' ), 'desc' => __( 'A shopper reached block checkout (their email + cart were captured) but did not pay — for GUESTS and registered customers alike. Sourced from the durable Abandoned-Carts capture (wp_aieo_carts), NOT live checkout-draft orders, so it survives WooCommerce’s ~24h draft purge. AutomateWoo “Guest Abandoned Cart”.', 'ai-eshop-optimizer' ), 'config' => array( 'min_age_hours' => array( 'type' => 'number', 'label' => __( 'Wait this many hours after checkout before firing', 'ai-eshop-optimizer' ), 'default' => 1 ), 'recheck' => array( 'type' => 'checkbox', 'label' => __( 'Recheck the cart is still unconverted before sending', 'ai-eshop-optimizer' ), 'default' => true ), ), 'has_order_rules' => true, 'wired' => true, ), 'birthday' => array( 'label' => __( 'Birthday', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires ahead of a customer’s birthday.', 'ai-eshop-optimizer' ), 'config' => array( 'days_before' => array( 'type' => 'number', 'label' => __( 'Days before birthday', 'ai-eshop-optimizer' ), 'default' => 14 ) ), 'wired' => true, ), 'welcome' => array( 'label' => __( 'New customer (welcome)', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires once, shortly after a customer registers.', 'ai-eshop-optimizer' ), 'config' => array( 'hours' => array( 'type' => 'number', 'label' => __( 'Registered within (hours)', 'ai-eshop-optimizer' ), 'default' => 48 ) ), 'wired' => true, ), 'back_in_stock' => array( 'label' => __( 'Back in stock', 'ai-eshop-optimizer' ), 'desc' => __( 'A waitlisted product is restocked (registered customers).', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'win_back' => array( 'label' => __( 'Win-back (went dormant)', 'ai-eshop-optimizer' ), 'desc' => __( 'A customer just crossed into a dormancy cohort. Set the cohort via the Audience filter (e.g. the “Lapsing” preset).', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'order_completed' => array( 'label' => __( 'Purchase (order completed)', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires after an order reaches the chosen status. Add Order rules (items / categories / brands / total, customer role) + an Offer step to deliver a POST-SALE coupon to the billing email — the AutomateWoo “Order Completed” workflow (e.g. bought shampoo, not the mask → 7 days later, a mask coupon).', 'ai-eshop-optimizer' ), 'config' => array( 'status' => array( 'type' => 'select', 'label' => __( 'Fires on status', 'ai-eshop-optimizer' ), 'options' => array( 'completed' => 'Completed', 'processing' => 'Processing (paid)', 'on-hold' => 'On hold', 'pending' => 'Pending payment', 'cancelled' => 'Cancelled', 'refunded' => 'Refunded (full)', 'failed' => 'Failed', 'checkout-draft' => 'Abandoned checkout (draft order — WooCommerce 10.x only)' ), 'default' => 'completed' ), 'recheck' => array( 'type' => 'checkbox', 'label' => __( 'Recheck the order still qualifies before sending', 'ai-eshop-optimizer' ), 'default' => true ), ), 'has_order_rules' => true, 'wired' => true, ), 'opted_in' => array( 'label' => __( 'Marketing opt-in', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires when a customer opts IN to marketing (newsletter confirm, or removal from the suppression list). AutomateWoo “Customer Opted In”. Works for guests (email-keyed) and registered customers.', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'opted_out' => array( 'label' => __( 'Marketing opt-out', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires when a customer opts OUT of marketing (unsubscribe, or addition to the suppression list). AutomateWoo “Customer Opted Out”. A message send is still blocked by the opt-out itself — typically paired with a mutation/log action (tag, change role, custom function).', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'guest_created' => array( 'label' => __( 'New guest captured', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires once ever per email when a GUEST (no account) places an order — a new-guest welcome / account-nudge. AutomateWoo “Guest Created”. Email-delivered to the captured billing address.', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'survey_detractor' => array( 'label' => __( 'Survey: detractor', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires the moment a customer submits the post-order survey with a DETRACTOR rating (1–2 of 5) — a service-recovery / apology flow. Pair with a goodwill Offer step or a mutation action (tag, alert, change role). Event-keyed to the surveyed customer (registered → user run; guest → email run).', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'survey_promoter' => array( 'label' => __( 'Survey: promoter', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires the moment a customer submits the post-order survey with a PROMOTER rating (4–5 of 5) — a referral / review-request / loyalty-reward flow. Event-keyed to the surveyed customer.', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'survey_answered' => array( 'label' => __( 'User Survey answered', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires when a survey comes back. Optionally only when one particular question was answered a particular way, such as "delivery speed 2 or less" or "would not order again", so a recovery flow reacts to the answer rather than to the response.', 'ai-eshop-optimizer' ), 'config' => array( 'survey' => array( 'type' => 'select', 'label' => __( 'Which survey', 'ai-eshop-optimizer' ), 'options' => self::survey_options(), 'default' => '' ), 'question' => array( 'type' => 'text', 'label' => __( 'Only when this question code was answered (optional)', 'ai-eshop-optimizer' ), 'default' => '' ), 'operator' => array( 'type' => 'select', 'label' => __( 'Compared how', 'ai-eshop-optimizer' ), 'options' => array( 'any' => 'Answered at all', 'is' => 'is', 'not' => 'is not', 'lte' => 'is at most', 'gte' => 'is at least' ), 'default' => 'any' ), 'value' => array( 'type' => 'text', 'label' => __( 'This value', 'ai-eshop-optimizer' ), 'default' => '' ), ), 'wired' => true, ), 'withdrawal_recorded' => array( 'label' => __( 'Withdrawal recorded', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires when a customer declares a withdrawal. The one thing a shop cannot answer for itself is why. Asking is optional, and it must never look like a condition of the refund.', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'return_approved' => array( 'label' => __( 'Return approved', 'ai-eshop-optimizer' ), 'desc' => __( 'Fires when a return is approved.', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), 'manual' => array( 'label' => __( 'Manual run', 'ai-eshop-optimizer' ), 'desc' => __( 'Run once, on demand, against the audience.', 'ai-eshop-optimizer' ), 'config' => array(), 'wired' => true, ), ); } public static function step_catalog() { $safe_roles = class_exists( 'AIEO_Safe_Roles' ) ? AIEO_Safe_Roles::roles() : array(); return array( 'ask_survey' => array( 'label' => __( 'Ask a survey', 'ai-eshop-optimizer' ), 'fields' => array( 'survey' => array( 'type' => 'select', 'label' => __( 'Which survey', 'ai-eshop-optimizer' ), 'options' => self::survey_options(), 'default' => '' ), 'channel' => array( 'type' => 'select', 'label' => __( 'Channel', 'ai-eshop-optimizer' ), 'options' => array( 'cascade' => 'Push first, email fallback', 'push' => 'Push only', 'email' => 'Email only' ), 'default' => 'email' ), 'subject' => array( 'type' => 'text', 'label' => __( 'Subject / push title', 'ai-eshop-optimizer' ), 'default' => '' ), 'body' => array( 'type' => 'textarea', 'label' => __( 'Message body (the link is added if you do not place it yourself)', 'ai-eshop-optimizer' ), 'default' => '', 'allow_html' => true ), 'frequency_cap' => array( 'type' => 'number', 'label' => __( 'Do not ask the same person again within (days)', 'ai-eshop-optimizer' ), 'default' => 90 ), ), ), 'wait' => array( 'label' => __( 'Wait', 'ai-eshop-optimizer' ), 'fields' => array( 'value' => array( 'type' => 'number', 'label' => __( 'Amount', 'ai-eshop-optimizer' ), 'default' => 1 ), 'unit' => array( 'type' => 'select', 'label' => __( 'Unit', 'ai-eshop-optimizer' ), 'options' => array( 'hours' => 'hours', 'days' => 'days' ), 'default' => 'days' ), ), ), 'send' => array( 'label' => __( 'Send message', 'ai-eshop-optimizer' ), 'fields' => array( 'channel' => array( 'type' => 'select', 'label' => __( 'Channel', 'ai-eshop-optimizer' ), 'options' => array( 'cascade' => 'Push first, email fallback', 'push' => 'Push only', 'email' => 'Email only' ), 'default' => 'cascade' ), 'subject' => array( 'type' => 'text', 'label' => __( 'Subject / push title', 'ai-eshop-optimizer' ), 'default' => '' ), 'body' => array( 'type' => 'textarea', 'label' => __( 'Message body', 'ai-eshop-optimizer' ), 'default' => '', 'allow_html' => true ), 'variant_subject' => array( 'type' => 'text', 'label' => __( 'A/B variant B — subject (optional)', 'ai-eshop-optimizer' ), 'default' => '' ), 'variant_body' => array( 'type' => 'textarea', 'label' => __( 'A/B variant B — body (optional; leave both blank for no A/B)', 'ai-eshop-optimizer' ), 'default' => '', 'allow_html' => true ), 'product_source' => array( 'type' => 'select', 'label' => __( 'Products', 'ai-eshop-optimizer' ), 'options' => array( 'none' => 'None', 'recommendations' => 'Behavioural recommendations', 'trigger_product' => 'The trigger’s product' ), 'default' => 'recommendations' ), 'offer' => array( 'type' => 'select', 'label' => __( 'Offer', 'ai-eshop-optimizer' ), 'options' => array( 'none' => 'No coupon', 'percent' => '% off', 'fixed' => sprintf( '%s off', html_entity_decode( get_woocommerce_currency_symbol() ) ) ), 'default' => 'none' ), 'offer_value' => array( 'type' => 'number', 'label' => __( 'Offer amount', 'ai-eshop-optimizer' ), 'default' => 10 ), 'offer_ttl_days' => array( 'type' => 'number', 'label' => __( 'Coupon valid (days)', 'ai-eshop-optimizer' ), 'default' => 14 ), ), ), 'social_post' => array( 'label' => __( 'Post to social', 'ai-eshop-optimizer' ), 'fields' => array( 'platforms' => array( 'type' => 'multiselect', 'label' => __( 'Channels', 'ai-eshop-optimizer' ), 'options' => array( 'facebook' => 'Facebook', 'instagram' => 'Instagram', 'x' => 'X', 'linkedin' => 'LinkedIn', 'tiktok' => 'TikTok' ), 'default' => array() ), 'object_type' => array( 'type' => 'text', 'label' => __( 'What to post about (type)', 'ai-eshop-optimizer' ), 'default' => 'product' ), 'object_id' => array( 'type' => 'number', 'label' => __( 'What to post about (ID)', 'ai-eshop-optimizer' ), 'default' => 0 ), 'body' => array( 'type' => 'textarea', 'label' => __( 'Caption (blank = AI-drafted per channel)', 'ai-eshop-optimizer' ), 'default' => '', 'allow_html' => false ), 'body_facebook' => array( 'type' => 'textarea', 'label' => __( 'Facebook caption (optional override)', 'ai-eshop-optimizer' ), 'default' => '' ), 'body_instagram' => array( 'type' => 'textarea', 'label' => __( 'Instagram caption (optional override)', 'ai-eshop-optimizer' ), 'default' => '' ), 'body_x' => array( 'type' => 'textarea', 'label' => __( 'X post (optional override)', 'ai-eshop-optimizer' ), 'default' => '' ), 'body_linkedin' => array( 'type' => 'textarea', 'label' => __( 'LinkedIn post (optional override)', 'ai-eshop-optimizer' ), 'default' => '' ), 'body_tiktok' => array( 'type' => 'textarea', 'label' => __( 'TikTok caption (optional override)', 'ai-eshop-optimizer' ), 'default' => '' ), ), ), 'custom_function' => array( 'label' => __( 'Custom function (developer action)', 'ai-eshop-optimizer' ), 'fields' => array( 'function_name' => array( 'type' => 'text', 'label' => __( 'Function / hook name', 'ai-eshop-optimizer' ), 'default' => '' ), ), ), 'change_order_status' => array( 'label' => __( 'Change order status', 'ai-eshop-optimizer' ), 'fields' => array( 'order_status' => array( 'type' => 'select', 'label' => __( 'New status', 'ai-eshop-optimizer' ), 'options' => array( 'processing' => 'Processing', 'completed' => 'Completed', 'on-hold' => 'On hold', 'cancelled' => 'Cancelled', 'refunded' => 'Refunded', 'pending' => 'Pending', 'failed' => 'Failed' ), 'default' => 'completed' ), 'note' => array( 'type' => 'text', 'label' => __( 'Status-change note (optional)', 'ai-eshop-optimizer' ), 'default' => '' ), ), ), 'order_add_note' => array( 'label' => __( 'Add order note', 'ai-eshop-optimizer' ), 'fields' => array( 'note' => array( 'type' => 'textarea', 'label' => __( 'Note', 'ai-eshop-optimizer' ), 'default' => '' ), 'customer_note' => array( 'type' => 'checkbox', 'label' => __( 'Visible to the customer (customer note)', 'ai-eshop-optimizer' ), 'default' => false ), ), ), 'customer_change_role' => array( 'label' => __( 'Change customer role', 'ai-eshop-optimizer' ), 'fields' => array( 'mode' => array( 'type' => 'select', 'label' => __( 'Change', 'ai-eshop-optimizer' ), 'options' => array( 'add' => __( 'Add the role', 'ai-eshop-optimizer' ), 'remove' => __( 'Remove the role', 'ai-eshop-optimizer' ), 'replace' => __( 'Replace every role with it', 'ai-eshop-optimizer' ), ), 'default' => 'add', ), 'role' => array( 'type' => 'select', 'label' => __( 'Customer role', 'ai-eshop-optimizer' ), 'options' => $safe_roles, 'default' => isset( $safe_roles['customer'] ) ? 'customer' : (string) key( $safe_roles ) ), ), ), 'order_update_meta' => array( 'label' => __( 'Update order meta', 'ai-eshop-optimizer' ), 'fields' => array( 'meta_key' => array( 'type' => 'text', 'label' => __( 'Meta key', 'ai-eshop-optimizer' ), 'default' => '' ), 'meta_value' => array( 'type' => 'text', 'label' => __( 'Meta value', 'ai-eshop-optimizer' ), 'default' => '' ), ), ), 'customer_update_meta' => array( 'label' => __( 'Update customer meta', 'ai-eshop-optimizer' ), 'fields' => array( 'meta_key' => array( 'type' => 'text', 'label' => __( 'Meta key', 'ai-eshop-optimizer' ), 'default' => '' ), 'meta_value' => array( 'type' => 'text', 'label' => __( 'Meta value', 'ai-eshop-optimizer' ), 'default' => '' ), ), ), ); } public static function save( $data ) { global $wpdb; $t = aieo_workflows_table(); if ( ! self::table( $t ) ) { return 0; } $now = gmdate( 'Y-m-d H:i:s' ); $trig = sanitize_key( (string) ( $data['trigger_type'] ?? '' ) ); $cat = self::trigger_catalog(); if ( ! isset( $cat[ $trig ] ) ) { $trig = 'manual'; } $steps = self::sanitize_steps( $data['steps'] ?? array() ); $status = in_array( ( $data['status'] ?? 'draft' ), array( 'draft', 'active', 'paused' ), true ) ? $data['status'] : 'draft'; $cfg = is_array( $data['trigger_config'] ?? null ) ? $data['trigger_config'] : array(); $id = (int) ( $data['id'] ?? 0 ); $is_update = ( $id > 0 && (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM `{$t}` WHERE id=%d", $id ) ) === $id ); if ( $is_update ) { $existing = self::get( $id ); $prev_cfg = ( $existing && is_array( $existing['trigger_config'] ?? null ) ) ? $existing['trigger_config'] : array(); foreach ( array( 'proposal_kind', 'proposal_predicted_ev', 'holdout_pct', 'optimize_send_time', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term' ) as $sys_key ) { if ( ! isset( $cfg[ $sys_key ] ) && isset( $prev_cfg[ $sys_key ] ) ) { $cfg[ $sys_key ] = $prev_cfg[ $sys_key ]; } } } $row = array( 'name' => sanitize_text_field( (string) ( $data['name'] ?? '' ) ), 'status' => $status, 'trigger_type' => $trig, 'trigger_config' => wp_json_encode( $cfg ), 'audience_id' => (int) ( $data['audience_id'] ?? 0 ), 'steps_json' => wp_json_encode( $steps ), 'updated' => $now, ); if ( $is_update ) { $wpdb->update( $t, $row, array( 'id' => $id ) ); $final_id = $id; } else { $row['created'] = $now; $wpdb->insert( $t, $row ); $final_id = (int) $wpdb->insert_id; } if ( $final_id > 0 && class_exists( 'AIEO_Campaign_I18n' ) ) { AIEO_Campaign_I18n::register_workflow( $final_id, $steps ); } return $final_id; } public static function get( $id ) { global $wpdb; $t = aieo_workflows_table(); if ( ! self::table( $t ) ) { return null; } $r = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `{$t}` WHERE id=%d", (int) $id ), ARRAY_A ); return $r ? self::hydrate( $r ) : null; } public static function list_all() { global $wpdb; $t = aieo_workflows_table(); if ( ! self::table( $t ) ) { return array(); } $rows = $wpdb->get_results( "SELECT * FROM `{$t}` ORDER BY updated DESC LIMIT 200", ARRAY_A ); return array_map( array( __CLASS__, 'hydrate' ), (array) $rows ); } public static function delete( $id ) { global $wpdb; $t = aieo_workflows_table(); if ( ! self::table( $t ) || (int) $id <= 0 ) { return false; } $wpdb->delete( aieo_workflow_runs_table(), array( 'workflow_id' => (int) $id ) ); return (bool) $wpdb->delete( $t, array( 'id' => (int) $id ), array( '%d' ) ); } public static function set_status( $id, $status ) { global $wpdb; $t = aieo_workflows_table(); if ( ! self::table( $t ) || ! in_array( $status, array( 'draft', 'active', 'paused' ), true ) ) { return false; } return false !== $wpdb->update( $t, array( 'status' => $status, 'updated' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => (int) $id ) ); } private static function hydrate( $r ) { $r['trigger_config'] = json_decode( (string) ( $r['trigger_config'] ?? '' ), true ) ?: array(); $r['steps'] = json_decode( (string) ( $r['steps_json'] ?? '' ), true ) ?: array(); foreach ( $r['steps'] as $i => $step ) { if ( is_array( $step ) && 'customer_change_role' === ( $step['type'] ?? '' ) && ! isset( $step['mode'] ) ) { $r['steps'][ $i ]['mode'] = 'replace'; } } $r['audience_id'] = (int) $r['audience_id']; $r['id'] = (int) $r['id']; unset( $r['steps_json'] ); return $r; } private static function sanitize_steps( $steps ) { $cat = self::step_catalog(); $out = array(); foreach ( (array) $steps as $s ) { $type = sanitize_key( (string) ( $s['type'] ?? '' ) ); if ( ! isset( $cat[ $type ] ) ) { continue; } $clean = array( 'type' => $type ); foreach ( $cat[ $type ]['fields'] as $f => $def ) { $val = $s[ $f ] ?? ( $def['default'] ?? '' ); if ( 'number' === $def['type'] ) { $clean[ $f ] = (float) $val; } elseif ( 'textarea' === $def['type'] ) { $clean[ $f ] = ! empty( $def['allow_html'] ) ? wp_kses_post( (string) $val ) : sanitize_textarea_field( (string) $val ); } elseif ( 'select' === $def['type'] ) { $clean[ $f ] = isset( $def['options'][ $val ] ) ? (string) $val : (string) $def['default']; } elseif ( 'checkbox' === $def['type'] ) { $clean[ $f ] = ( empty( $val ) || 'false' === $val || 'off' === $val ) ? 0 : 1; } elseif ( 'multiselect' === $def['type'] ) { $vals = is_array( $val ) ? $val : ( '' === (string) $val ? array() : explode( ',', (string) $val ) ); $allowed = array_map( 'strval', array_keys( (array) ( $def['options'] ?? array() ) ) ); $clean[ $f ] = array_values( array_intersect( array_unique( array_map( 'sanitize_key', $vals ) ), $allowed ) ); } else { $clean[ $f ] = sanitize_text_field( (string) $val ); } } $out[] = $clean; } return $out; } private static function table( $t ) { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t ) ) === $t; } }