get_id() : (int) $order );
}, 20, 2 );
add_action( 'aieo_withdrawal_return_approved', static function ( $declaration_id, $order ) {
unset( $declaration_id );
self::on_withdrawal_event( 'return_approved', is_object( $order ) && method_exists( $order, 'get_id' ) ? $order->get_id() : (int) $order );
}, 20, 2 );
add_action( 'aieo_customer_opted_in', array( __CLASS__, 'on_opted_in' ), 10, 1 );
add_action( 'aieo_customer_opted_out', array( __CLASS__, 'on_opted_out' ), 10, 1 );
add_action( 'newsletter_user_confirmed', array( __CLASS__, 'on_tnp_confirmed' ), 10, 1 );
add_action( 'newsletter_user_unsubscribed', array( __CLASS__, 'on_tnp_unsub' ), 10, 1 );
}
public static function maybe_schedule() {
if ( class_exists( 'AIEO_Schedule_Gate', false ) && ! AIEO_Schedule_Gate::due( __METHOD__ ) ) {
return;
}
static $checked = false;
if ( $checked ) {
return;
}
$checked = true;
if ( get_transient( 'aieo_workflow_sched_lock' ) ) {
return;
}
set_transient( 'aieo_workflow_sched_lock', 1, 5 * MINUTE_IN_SECONDS );
if ( function_exists( 'as_next_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ) ) {
if ( false === as_next_scheduled_action( self::HOOK_TICK, null, 'aieo' ) ) {
as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, self::HOOK_TICK, array(), 'aieo' );
}
} elseif ( ! wp_next_scheduled( self::HOOK_TICK ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::HOOK_TICK );
}
}
public static function dedup_pre_schedule( $pre, $timestamp, $interval, $hook, $args = array(), $group = '' ) {
if ( self::HOOK_TICK !== $hook || 'aieo' !== $group ) {
return $pre;
}
global $wpdb;
$lk = AIEO_Automation_Test_Mode::lock_name( 'aieo_workflow_sched' );
if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lk ) ) ) {
return 1;
}
try {
$existing = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT a.action_id FROM {$wpdb->prefix}actionscheduler_actions a
LEFT JOIN {$wpdb->prefix}actionscheduler_groups g ON g.group_id = a.group_id
WHERE a.hook = %s AND a.status IN ('pending','in-progress') AND g.slug = %s
ORDER BY a.action_id ASC LIMIT 1",
self::HOOK_TICK,
'aieo'
) );
return $existing > 0 ? $existing : $pre;
} finally {
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lk ) );
}
}
public static function tick() {
if ( ! self::ready() ) {
return;
}
if ( function_exists( 'aieo_subjects_maybe_install' ) ) {
aieo_subjects_maybe_install();
}
if ( class_exists( 'AIEO_DMM_Module_Base' ) && ! AIEO_DMM_Module_Base::is_aieo_pro_user() ) {
return;
}
global $wpdb;
$lock = AIEO_Automation_Test_Mode::lock_name();
if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ) ) {
return;
}
try {
foreach ( AIEO_Workflows::list_all() as $wf ) {
if ( 'active' !== $wf['status'] ) {
continue;
}
self::fire_trigger( $wf );
}
$more = self::process_runs( self::BATCH );
if ( $more && function_exists( 'as_enqueue_async_action' ) ) {
as_enqueue_async_action( self::HOOK_TICK, array(), 'aieo' );
}
} finally {
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
}
}
private static function fire_trigger( $wf ) {
switch ( $wf['trigger_type'] ) {
case 'scheduled':
if ( self::channels_live( $wf ) ) {
self::fire_scheduled( $wf );
}
break;
case 'abandoned_cart':
if ( self::channels_live( $wf ) ) {
self::fire_abandoned_cart( $wf );
}
break;
case 'birthday':
if ( self::channels_live( $wf ) ) {
self::fire_birthday( $wf );
}
break;
case 'welcome':
if ( self::channels_live( $wf ) ) {
self::fire_welcome( $wf );
}
break;
case 'back_in_stock':
if ( self::channels_live( $wf ) ) {
self::fire_back_in_stock( $wf );
}
break;
case 'win_back':
if ( self::channels_live( $wf ) ) {
self::fire_win_back( $wf );
}
break;
case 'order_completed':
self::fire_order_completed( $wf );
break;
case 'guest_created':
self::fire_guest_created( $wf );
break;
case 'abandoned_checkout':
self::fire_abandoned_checkout( $wf );
break;
}
if ( class_exists( 'AIEO_Social_Broadcast' ) ) {
try {
AIEO_Social_Broadcast::maybe_launch( $wf );
} catch ( \Throwable $e ) {
}
}
}
public static function on_opted_in( $identity ) {
self::fire_consent_event( 'opted_in', $identity );
}
public static function on_opted_out( $identity ) {
self::fire_consent_event( 'opted_out', $identity );
}
public static function on_tnp_confirmed( $user ) {
$e = self::tnp_email( $user );
if ( '' !== $e ) {
self::fire_consent_event( 'opted_in', $e );
}
}
public static function on_tnp_unsub( $user ) {
$e = self::tnp_email( $user );
if ( '' !== $e ) {
self::fire_consent_event( 'opted_out', $e );
}
}
private static function tnp_email( $user ) {
if ( is_object( $user ) && ! empty( $user->email ) ) {
return (string) $user->email;
}
if ( is_array( $user ) && ! empty( $user['email'] ) ) {
return (string) $user['email'];
}
return '';
}
public static function on_survey_signal( $nps_class, $identity ) {
$class = (string) $nps_class;
if ( 'detractor' !== $class && 'promoter' !== $class ) {
return;
}
self::fire_consent_event( 'survey_' . $class, $identity );
}
public static function on_survey_answered( $entry_id, $blueprint, $answers, $ctx ) {
unset( $answers );
if ( ! self::ready() || ! class_exists( 'AIEO_Workflows' ) ) {
return;
}
$entry_id = (int) $entry_id;
$identity = array(
'user_id' => (int) ( isset( $ctx['user_id'] ) ? $ctx['user_id'] : 0 ),
'email' => (string) ( isset( $ctx['email'] ) ? $ctx['email'] : '' ),
);
$run_ctx = array(
'order_id' => (int) ( isset( $ctx['order_id'] ) ? $ctx['order_id'] : 0 ),
);
foreach ( AIEO_Workflows::list_all() as $wf ) {
if ( 'active' !== $wf['status'] || 'survey_answered' !== (string) $wf['trigger_type'] ) {
continue;
}
$cfg = is_array( $wf['trigger_config'] ?? null ) ? $wf['trigger_config'] : array();
$want = isset( $cfg['survey'] ) ? (string) $cfg['survey'] : '';
if ( '' !== $want && $want !== (string) $blueprint ) {
continue;
}
if ( ! self::survey_condition_met( $entry_id, $cfg ) ) {
continue;
}
self::fire_one_consent_workflow( $wf, $identity, $run_ctx );
}
}
private static function survey_condition_met( $entry_id, array $cfg ) {
$question = isset( $cfg['question'] ) ? sanitize_key( (string) $cfg['question'] ) : '';
$operator = isset( $cfg['operator'] ) ? (string) $cfg['operator'] : 'any';
if ( '' === $question || 'any' === $operator ) {
return true;
}
global $wpdb;
$values = AIEO_Form_CPT::entry_values_table();
$rows = (array) $wpdb->get_results( $wpdb->prepare(
"SELECT choice_key, value_num FROM {$values} WHERE entry_id = %d AND field_key = %s AND disposition = 'answered'",
(int) $entry_id,
$question
), ARRAY_A );
if ( ! $rows ) {
return false;
}
$want = isset( $cfg['value'] ) ? (string) $cfg['value'] : '';
foreach ( $rows as $row ) {
$code = (string) $row['choice_key'];
$num = ( null === $row['value_num'] ) ? null : (float) $row['value_num'];
switch ( $operator ) {
case 'is':
if ( $code === $want || ( null !== $num && (float) $want === $num ) ) {
return true;
}
break;
case 'not':
break;
case 'lte':
if ( null !== $num && $num <= (float) $want ) {
return true;
}
break;
case 'gte':
if ( null !== $num && $num >= (float) $want ) {
return true;
}
break;
}
}
if ( 'not' === $operator ) {
foreach ( $rows as $row ) {
if ( (string) $row['choice_key'] === $want ) {
return false;
}
}
return true;
}
return false;
}
public static function on_withdrawal_event( $trigger_type, $order_id ) {
if ( ! function_exists( 'wc_get_order' ) ) {
return;
}
$order = wc_get_order( (int) $order_id );
if ( ! $order ) {
return;
}
self::fire_consent_event( (string) $trigger_type, array(
'user_id' => (int) $order->get_customer_id(),
'email' => (string) $order->get_billing_email(),
) );
}
private static function fire_one_consent_workflow( $wf, $identity, $context = array() ) {
list( $uid, $email ) = self::resolve_identity( $identity );
if ( $uid <= 0 && '' === $email ) {
return;
}
if ( $uid > 0 ) {
$rule = self::audience_rule( $wf );
if ( $rule && class_exists( 'AIEO_Audience_Rules' ) && ! AIEO_Audience_Rules::matches_user( $rule, $uid ) ) {
return;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, (array) $context );
return;
}
$crule = self::audience_rule( $wf );
if ( $crule && class_exists( 'AIEO_Audience_Rules' ) && ! AIEO_Audience_Rules::matches_email( $crule, $email ) ) {
return;
}
self::enqueue_email_run( $wf, $email, (array) $context, 24 );
}
public static function fire_consent_event( $trigger_type, $identity ) {
if ( ! self::ready() || ! class_exists( 'AIEO_Workflows' ) ) {
return;
}
if ( class_exists( 'AIEO_DMM_Module_Base' ) && ! AIEO_DMM_Module_Base::is_aieo_pro_user() ) {
return;
}
list( $uid, $email ) = self::resolve_identity( $identity );
if ( $uid <= 0 && '' === $email ) {
return;
}
foreach ( AIEO_Workflows::list_all() as $wf ) {
if ( 'active' !== $wf['status'] || $trigger_type !== (string) $wf['trigger_type'] ) {
continue;
}
if ( $uid > 0 ) {
$rule = self::audience_rule( $wf );
if ( $rule && class_exists( 'AIEO_Audience_Rules' ) && ! AIEO_Audience_Rules::matches_user( $rule, $uid ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, array() );
} else {
$crule = self::audience_rule( $wf );
if ( $crule && class_exists( 'AIEO_Audience_Rules' )
&& ! AIEO_Audience_Rules::matches_email( $crule, $email ) ) {
continue;
}
self::enqueue_email_run( $wf, $email, array(), 24 );
}
}
}
private static function resolve_identity( $identity ) {
$uid = 0;
$email = '';
if ( is_int( $identity ) || ( is_string( $identity ) && ctype_digit( $identity ) ) ) {
$uid = (int) $identity;
} elseif ( is_string( $identity ) && is_email( $identity ) ) {
$email = strtolower( sanitize_email( $identity ) );
} elseif ( $identity instanceof WP_User ) {
$uid = (int) $identity->ID;
} elseif ( is_object( $identity ) ) {
if ( ! empty( $identity->user_id ) ) { $uid = (int) $identity->user_id; }
if ( $uid <= 0 && ! empty( $identity->ID ) ) { $uid = (int) $identity->ID; }
if ( ! empty( $identity->email ) ) { $email = strtolower( sanitize_email( (string) $identity->email ) ); }
} elseif ( is_array( $identity ) ) {
if ( ! empty( $identity['user_id'] ) ) { $uid = (int) $identity['user_id']; }
if ( ! empty( $identity['email'] ) ) { $email = strtolower( sanitize_email( (string) $identity['email'] ) ); }
}
if ( $uid <= 0 && '' !== $email ) {
$u = get_user_by( 'email', $email );
if ( $u instanceof WP_User ) { $uid = (int) $u->ID; }
}
if ( $uid > 0 && '' === $email ) {
$u = get_user_by( 'id', $uid );
if ( $u instanceof WP_User ) { $email = strtolower( (string) $u->user_email ); }
}
return array( $uid, $email );
}
private static function fire_guest_created( $wf ) {
if ( ! function_exists( 'wc_get_orders' ) ) {
return;
}
$since = time() - self::ORDER_LOOKBACK_HOURS * HOUR_IN_SECONDS;
$page = 1;
$guard = 0;
do {
$ids = wc_get_orders( array(
'status' => array( 'wc-processing', 'wc-completed', 'wc-on-hold' ),
'customer_id' => 0,
'limit' => self::BATCH,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
'date_created' => '>=' . $since,
'return' => 'ids',
) );
$n = count( (array) $ids );
foreach ( (array) $ids as $oid ) {
$order = wc_get_order( (int) $oid );
if ( ! $order || (int) $order->get_customer_id() > 0 ) {
continue;
}
if ( apply_filters( 'aieo_suppress_order_customer_email', false, $order, 'workflow' ) ) {
continue;
}
$email = (string) $order->get_billing_email();
if ( '' === $email ) {
continue;
}
if ( get_user_by( 'email', $email ) ) {
continue;
}
self::enqueue_email_run( $wf, $email, array( 'order_id' => (int) $oid, 'product_id' => self::first_product_id( $order ) ), -1 );
}
$page++;
$guard++;
} while ( $n >= self::BATCH && $guard < 100 );
}
private static function first_product_id( $order ) {
foreach ( $order->get_items() as $item ) {
$pid = (int) $item->get_product_id();
if ( $pid > 0 ) {
return $pid;
}
}
return 0;
}
private static function fire_abandoned_checkout( $wf ) {
global $wpdb;
if ( ! class_exists( 'AIEO_Carts_Store' ) ) {
return;
}
$carts = AIEO_Carts_Store::carts_table();
if ( (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $carts ) ) !== $carts ) {
return;
}
$cfg = (array) ( $wf['trigger_config'] ?? array() );
$min_age = max( 0, (int) ( $cfg['min_age_hours'] ?? 1 ) );
$rules = ( isset( $cfg['order_rules'] ) && is_array( $cfg['order_rules'] ) ) ? $cfg['order_rules'] : array();
$crule = self::audience_rule( $wf );
$after = 0;
$guard = 0;
do {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, wc_order_id, user_id, customer_email, items, total, coupons
FROM `{$carts}`
WHERE kind = 'abandoned' AND outcome = 'open' AND customer_email <> ''
AND wc_order_id IS NOT NULL AND id > %d
AND created_at <= (UTC_TIMESTAMP() - INTERVAL %d HOUR)
ORDER BY id ASC
LIMIT %d",
$after,
$min_age,
self::BATCH
), ARRAY_A );
$n = count( (array) $rows );
foreach ( (array) $rows as $r ) {
$after = (int) $r['id'];
$oid = (int) $r['wc_order_id'];
if ( $oid <= 0 || self::has_run_for_order( (int) $wf['id'], $oid ) ) {
continue;
}
if ( $rules && class_exists( 'AIEO_Order_Rules' ) ) {
$items = json_decode( (string) $r['items'], true );
$extra = array( 'total' => (float) $r['total'], 'coupons' => json_decode( (string) $r['coupons'], true ) ?: array(), 'email' => (string) $r['customer_email'] );
if ( ! AIEO_Order_Rules::matches_cart( is_array( $items ) ? $items : array(), $rules, $extra ) ) {
continue;
}
}
$uid = (int) $r['user_id'];
$email = (string) $r['customer_email'];
$pid = self::first_cart_product_id( (string) $r['items'] );
$ctx = array( 'order_id' => $oid, 'cart_id' => (int) $r['id'], 'product_id' => $pid );
if ( $crule && class_exists( 'AIEO_Audience_Rules' ) ) {
$in_audience = $uid > 0
? AIEO_Audience_Rules::matches_user( $crule, $uid )
: AIEO_Audience_Rules::matches_email( $crule, $email );
if ( ! $in_audience ) {
continue;
}
}
if ( $uid > 0 ) {
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, $ctx, true );
} else {
$ctx['guest_email'] = $email;
self::enqueue_run( $wf, 0, 0, $ctx, true, md5( strtolower( trim( $email ) ) ) );
}
}
$guard++;
} while ( $n >= self::BATCH && $guard < 100 );
}
private static function first_cart_product_id( $items_json ) {
$items = json_decode( (string) $items_json, true );
if ( is_array( $items ) ) {
foreach ( $items as $it ) {
$pid = (int) ( $it['product_id'] ?? 0 );
if ( $pid > 0 ) {
return $pid;
}
}
}
return 0;
}
private static function cart_still_abandoned( $ctx ) {
global $wpdb;
$cart_id = (int) ( $ctx['cart_id'] ?? 0 );
if ( $cart_id <= 0 || ! class_exists( 'AIEO_Carts_Store' ) ) {
return false;
}
$carts = AIEO_Carts_Store::carts_table();
$oid = (int) ( $ctx['order_id'] ?? 0 );
if ( $oid > 0 && function_exists( 'wc_get_order' ) ) {
$order = wc_get_order( $oid );
if ( $order instanceof WC_Order && ! in_array( $order->get_status(), array( 'checkout-draft', 'auto-draft', 'pending', 'failed' ), true ) ) {
return false;
}
}
$outcome = (string) $wpdb->get_var( $wpdb->prepare(
"SELECT outcome FROM `{$carts}` WHERE id = %d",
$cart_id
) );
return 'open' === $outcome;
}
private static function channels_live( $wf ) {
$sends = false;
foreach ( (array) ( $wf['steps'] ?? array() ) as $s ) {
if ( ! in_array( (string) ( $s['type'] ?? '' ), array( 'send', 'ask_survey' ), true ) ) {
continue;
}
$sends = true;
$ch = (string) ( $s['channel'] ?? 'cascade' );
$test = ( 'email' === $ch || 'push' === $ch ) ? AIEO_Automation_Test_Mode::test_on( $ch ) : AIEO_Automation_Test_Mode::any_test();
if ( self::channel_live( $ch ) || $test ) {
return true;
}
}
return ! $sends;
}
private static function fire_welcome( $wf ) {
global $wpdb;
$hours = max( 1, (int) ( $wf['trigger_config']['hours'] ?? 48 ) );
$since = gmdate( 'Y-m-d H:i:s', time() - $hours * HOUR_IN_SECONDS );
$runs = aieo_workflow_runs_table();
$no_test = ! AIEO_Automation_Test_Mode::any_test();
$args = array( (int) $wf['id'] );
if ( $no_test ) {
$args[] = '%' . $wpdb->esc_like( '"_test":1' ) . '%';
}
$args[] = $since;
$args[] = self::BATCH;
$uids = $wpdb->get_col( $wpdb->prepare(
"SELECT u.ID FROM {$wpdb->users} u
LEFT JOIN `{$runs}` r ON r.workflow_id = %d AND r.wp_user_id = u.ID
AND NOT ( r.state = 'cancelled' AND r.step_index = 0 )" . ( $no_test ? ' AND r.context_json NOT LIKE %s' : '' ) . "
WHERE u.user_registered >= %s AND r.id IS NULL
ORDER BY u.ID ASC LIMIT %d",
$args
) );
$rule = self::audience_rule( $wf );
foreach ( (array) $uids as $uid ) {
$uid = (int) $uid;
if ( $rule && ! AIEO_Audience_Rules::matches_user( $rule, $uid ) ) {
continue;
}
if ( self::has_run_ever( (int) $wf['id'], $uid ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, array() );
}
}
private static function fire_back_in_stock( $wf ) {
global $wpdb;
$wl = $wpdb->prefix . 'aieo_waitlist';
if ( (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wl ) ) !== $wl ) {
return;
}
$after = 0;
$guard = 0;
do {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT w.id AS wl_id, u.ID AS wp_user_id, w.email, w.product_id, w.variation_id,
COALESCE(NULLIF(w.variation_id,0), w.product_id) AS stock_pid
FROM `{$wl}` w
JOIN {$wpdb->postmeta} ss ON ss.post_id = COALESCE(NULLIF(w.variation_id,0), w.product_id)
AND ss.meta_key = '_stock_status' AND ss.meta_value = 'instock'
JOIN {$wpdb->posts} sp ON sp.ID = COALESCE(NULLIF(w.variation_id,0), w.product_id) AND sp.post_status = 'publish'
JOIN {$wpdb->posts} pp ON pp.ID = w.product_id AND pp.post_status = 'publish'
JOIN {$wpdb->users} u ON LOWER(u.user_email) COLLATE utf8mb4_general_ci = LOWER(w.email) COLLATE utf8mb4_general_ci
WHERE w.status = 'subscribed' AND w.notified_at IS NULL AND w.product_id > 0 AND w.id > %d
ORDER BY w.id ASC
LIMIT %d",
$after,
self::BATCH
), ARRAY_A );
foreach ( (array) $rows as $r ) {
$uid = (int) $r['wp_user_id'];
$stock_pid = (int) $r['stock_pid'];
$wl_id = (int) $r['wl_id'];
if ( $wl_id > $after ) {
$after = $wl_id;
}
if ( self::has_recent_run( (int) $wf['id'], $uid, 24 ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, array( 'product_id' => $stock_pid, 'wl_id' => $wl_id, 'wl_email' => (string) $r['email'] ) );
}
$guard++;
} while ( count( (array) $rows ) >= self::BATCH && $guard < 500 );
}
private static function fire_win_back( $wf ) {
global $wpdb;
$rule = self::audience_rule( $wf );
if ( ! $rule || ! class_exists( 'AIEO_Audience_Rules' ) ) {
return;
}
$ci = $wpdb->prefix . 'aieo_customer_intelligence';
if ( (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $ci ) ) !== $ci ) {
return;
}
$where = AIEO_Audience_Rules::compile( $rule );
$where = str_replace( '%', '%%', $where );
$a = AIEO_Audience_Rules::CI_ALIAS;
$meta_key = 'aieo_winback_fired_cohort_' . (int) $wf['id'];
$after = 0;
$guard = 0;
do {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT {$a}.wp_user_id, {$a}.dormancy_cohort FROM `{$ci}` {$a}
WHERE ({$where}) AND {$a}.wp_user_id > %d ORDER BY {$a}.wp_user_id ASC LIMIT %d",
$after,
self::BATCH
), ARRAY_A );
foreach ( (array) $rows as $r ) {
$uid = (int) $r['wp_user_id'];
$coh = (string) $r['dormancy_cohort'];
if ( $uid > $after ) {
$after = $uid;
}
if ( '' === $coh ) {
continue;
}
if ( (string) get_user_meta( $uid, $meta_key, true ) === $coh ) {
continue;
}
if ( AIEO_Automation_Test_Mode::any_test() && (string) get_user_meta( $uid, $meta_key . AIEO_Automation_Test_Mode::SUFFIX, true ) === $coh ) {
continue;
}
if ( self::has_recent_run( (int) $wf['id'], $uid, self::WINBACK_MIN_GAP_HOURS ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, array( 'cohort' => $coh, 'winback_meta_key' => $meta_key ) );
}
$guard++;
} while ( count( (array) $rows ) >= self::BATCH && $guard < 500 );
}
private static function guard_test_sql() {
global $wpdb;
if ( AIEO_Automation_Test_Mode::any_test() ) {
return '';
}
return $wpdb->prepare( ' AND context_json NOT LIKE %s', '%' . $wpdb->esc_like( '"_test":1' ) . '%' );
}
private static function has_run_ever( $wf_id, $uid ) {
global $wpdb;
$t = aieo_workflow_runs_table();
return (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM `{$t}` WHERE workflow_id = %d AND wp_user_id = %d AND NOT ( state = 'cancelled' AND step_index = 0 )",
(int) $wf_id,
(int) $uid
) . self::guard_test_sql() ) > 0;
}
private static function push_text( $html, $coupon = null ) {
$t = preg_replace( '~
|(?:p|div|li|h[1-6]|tr|blockquote)\s*>~i', "\n", (string) $html );
$t = html_entity_decode( wp_strip_all_tags( (string) $t ), ENT_QUOTES, 'UTF-8' );
$lines = array();
foreach ( preg_split( '~\R~u', $t ) ?: array() as $line ) {
$line = trim( (string) preg_replace( '~[ \t\x{00A0}]+~u', ' ', (string) $line ) );
if ( '' !== $line ) {
$lines[] = $line;
}
}
if ( is_array( $coupon ) && ! empty( $coupon['code'] ) ) {
$lines[] = (string) $coupon['label'] . ': ' . strtoupper( (string) $coupon['code'] );
}
return implode( "\n", $lines );
}
private static function service_address( $run, array $ctx ) {
$uid = (int) ( $run['wp_user_id'] ?? 0 );
if ( $uid > 0 ) {
$u = get_userdata( $uid );
return $u ? strtolower( (string) $u->user_email ) : '';
}
return strtolower( (string) ( $ctx['guest_email'] ?? '' ) );
}
private static function tester_addresses( $run, array $ctx ) {
$uid = (int) ( $run['wp_user_id'] ?? 0 );
$u = ( $uid > 0 && AIEO_Automation_Test_Mode::is_tester( $uid ) ) ? get_userdata( $uid ) : null;
if ( ! $u ) {
return $ctx;
}
foreach ( array( 'bill_email', 'wl_email', 'guest_email' ) as $k ) {
$a = (string) ( $ctx[ $k ] ?? '' );
if ( '' !== $a && ! AIEO_Automation_Test_Mode::is_tester( 0, $a ) ) {
$ctx[ $k ] = strtolower( (string) $u->user_email );
}
}
return $ctx;
}
private static function dedup_args( $wf, $i ) {
$week = 7 * DAY_IN_SECONDS;
$secs = $week;
if ( 'scheduled' === (string) ( $wf['trigger_type'] ?? '' ) ) {
$every = array( 'daily' => DAY_IN_SECONDS, 'weekly' => $week, 'monthly' => 30 * DAY_IN_SECONDS );
$secs = (int) min( $week, max( HOUR_IN_SECONDS, ( $every[ (string) ( $wf['trigger_config']['interval'] ?? 'weekly' ) ] ?? $week ) - 4 * HOUR_IN_SECONDS ) );
}
return array( 'dedup_step' => max( 0, (int) $i ), 'dedup_secs' => $secs );
}
private static function fire_order_completed( $wf ) {
if ( ! function_exists( 'wc_get_orders' ) ) {
return;
}
$cfg = (array) ( $wf['trigger_config'] ?? array() );
$status = self::order_trigger_status( $wf );
$rules = ( isset( $cfg['order_rules'] ) && is_array( $cfg['order_rules'] ) ) ? $cfg['order_rules'] : array();
$crule = self::audience_rule( $wf );
$date_field = ( 'completed' === $status ) ? 'date_completed'
: ( ( 'processing' === $status ) ? 'date_paid' : 'date_modified' );
$since = time() - self::ORDER_LOOKBACK_HOURS * HOUR_IN_SECONDS;
$date_arg = '>=' . $since;
if ( 'checkout-draft' === $status ) {
if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '11.0', '>=' ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf(
'[AIEO] Workflow #%d triggers on order status checkout-draft, which WooCommerce %s no longer creates. Switch it to the "Abandoned checkout (cart recovery)" trigger.',
isset( $wf['id'] ) ? (int) $wf['id'] : 0,
WC_VERSION
) );
}
return;
}
$upper = time() - self::CHECKOUT_DRAFT_GRACE_MIN * MINUTE_IN_SECONDS;
$date_arg = $since . '...' . max( $since, $upper );
}
$page = 1;
$guard = 0;
do {
$ids = wc_get_orders( array(
'status' => 'wc-' . $status,
'limit' => self::BATCH,
'paged' => $page,
'orderby' => 'ID',
'order' => 'ASC',
$date_field => $date_arg,
'return' => 'ids',
) );
$n = count( (array) $ids );
foreach ( (array) $ids as $oid ) {
$oid = (int) $oid;
if ( self::has_run_for_order( (int) $wf['id'], $oid ) ) {
continue;
}
$order = wc_get_order( $oid );
if ( ! $order ) {
continue;
}
if ( apply_filters( 'aieo_suppress_order_customer_email', false, $order, 'workflow' ) ) {
continue;
}
if ( class_exists( 'AIEO_Order_Rules' ) && ! AIEO_Order_Rules::matches( $order, $rules ) ) {
continue;
}
$uid = (int) $order->get_customer_id();
if ( $crule && class_exists( 'AIEO_Audience_Rules' ) ) {
$bill_email = (string) $order->get_billing_email();
$in_audience = $uid > 0
? AIEO_Audience_Rules::matches_user( $crule, $uid )
: AIEO_Audience_Rules::matches_email( $crule, $bill_email );
if ( ! $in_audience ) {
continue;
}
}
$email = (string) $order->get_billing_email();
if ( '' === $email ) {
continue;
}
$ehash = md5( strtolower( trim( $email ) ) );
if ( ! empty( $cfg['once_per_customer'] )
&& ( $uid > 0 ? self::has_run_ever( (int) $wf['id'], $uid ) : self::has_email_run( (int) $wf['id'], $ehash, -1 ) ) ) {
continue;
}
$sid = ( $uid > 0 && class_exists( 'AIEO_Subject_Resolver' ) ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, array( 'order_id' => $oid, 'bill_email' => $email ), true, ( ! empty( $cfg['once_per_customer'] ) && $uid <= 0 ) ? $ehash : '' );
}
$page++;
$guard++;
} while ( $n >= self::BATCH && $guard < 100 );
}
private static function has_run_for_order( $wf_id, $order_id ) {
global $wpdb;
$t = aieo_workflow_runs_table();
return (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM `{$t}` WHERE workflow_id = %d AND order_id = %d AND NOT ( state = 'cancelled' AND step_index = 0 )",
(int) $wf_id,
(int) $order_id
) . self::guard_test_sql() ) > 0;
}
private static function order_trigger_statuses() {
return array( 'completed', 'processing', 'on-hold', 'pending', 'cancelled', 'refunded', 'failed', 'checkout-draft' );
}
private static function order_trigger_status( $wf ) {
$s = (string) ( $wf['trigger_config']['status'] ?? 'completed' );
return in_array( $s, self::order_trigger_statuses(), true ) ? $s : 'completed';
}
private static function order_still_qualifies( $ctx, $wf ) {
$oid = (int) ( $ctx['order_id'] ?? 0 );
if ( $oid <= 0 || ! function_exists( 'wc_get_order' ) ) {
return false;
}
$order = wc_get_order( $oid );
if ( ! $order instanceof WC_Order ) {
return false;
}
$want = self::order_trigger_status( $wf );
$current = $order->get_status();
$still = ( $current === $want )
|| ( 'processing' === $want && 'completed' === $current );
if ( ! $still ) {
return false;
}
$rules = ( isset( $wf['trigger_config']['order_rules'] ) && is_array( $wf['trigger_config']['order_rules'] ) )
? $wf['trigger_config']['order_rules'] : array();
return ! ( class_exists( 'AIEO_Order_Rules' ) && ! AIEO_Order_Rules::matches( $order, $rules ) );
}
private static function fire_scheduled( $wf ) {
$interval = (string) ( $wf['trigger_config']['interval'] ?? 'weekly' );
$secs = array( 'daily' => DAY_IN_SECONDS, 'weekly' => 7 * DAY_IN_SECONDS, 'monthly' => 30 * DAY_IN_SECONDS );
$gap = $secs[ $interval ] ?? ( 7 * DAY_IN_SECONDS );
if ( AIEO_Automation_Test_Mode::any_test() && ! self::reaches_customers( $wf ) ) {
$key = 'aieo_wf_sched_' . (int) $wf['id'] . AIEO_Automation_Test_Mode::SUFFIX;
if ( get_transient( $key ) ) {
return;
}
set_transient( $key, time(), max( HOUR_IN_SECONDS, $gap - HOUR_IN_SECONDS ) );
self::enqueue_audience( $wf );
return;
}
$last = $wf['last_run_at'] ? strtotime( $wf['last_run_at'] . ' UTC' ) : 0;
if ( $last && ( time() - $last ) < $gap ) {
return;
}
self::enqueue_audience( $wf );
self::mark_run( $wf['id'] );
}
public static function run_for_tester( $workflow_id, $wp_user_id, $email = '' ) {
global $wpdb;
if ( ! AIEO_Automation_Test_Mode::any_test() ) {
return new WP_Error( 'aieo_not_test', __( 'Switch e-mail or push to Test first.', 'ai-eshop-optimizer' ) );
}
$wf = AIEO_Workflows::get( (int) $workflow_id );
if ( ! $wf ) {
return new WP_Error( 'aieo_no_workflow', __( 'Workflow not found.', 'ai-eshop-optimizer' ) );
}
$uid = (int) $wp_user_id;
$email = strtolower( sanitize_email( (string) $email ) );
if ( $uid <= 0 && '' !== $email ) {
$u = get_user_by( 'email', $email );
$uid = $u ? (int) $u->ID : 0;
}
if ( ! AIEO_Automation_Test_Mode::is_tester( $uid, $email ) ) {
return new WP_Error( 'aieo_not_tester', __( 'Only a tester can be run. Add them to the testers first.', 'ai-eshop-optimizer' ) );
}
$ctx = self::tester_context( $wf, $uid, $email );
$sid = ( $uid > 0 && class_exists( 'AIEO_Subject_Resolver' ) ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
if ( $uid <= 0 ) {
$ctx['guest_email'] = $email;
}
if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', AIEO_Automation_Test_Mode::lock_name(), 10 ) ) ) {
return new WP_Error( 'aieo_busy', __( 'The automations are working through their queue right now. Try again in a minute.', 'ai-eshop-optimizer' ) );
}
try {
self::$last_run_id = 0;
if ( ! self::enqueue_run( $wf, $uid, $sid, $ctx, true, $uid > 0 ? '' : md5( $email ) ) || self::$last_run_id <= 0 ) {
return new WP_Error( 'aieo_no_run', __( 'The run could not be started.', 'ai-eshop-optimizer' ) );
}
$t = aieo_workflow_runs_table();
$run = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `{$t}` WHERE id = %d", self::$last_run_id ), ARRAY_A );
if ( ! $run ) {
return new WP_Error( 'aieo_no_run', __( 'The run could not be started.', 'ai-eshop-optimizer' ) );
}
self::process_run( $run );
} finally {
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', AIEO_Automation_Test_Mode::lock_name() ) );
}
$after = $wpdb->get_row( $wpdb->prepare( "SELECT state, context_json FROM `{$t}` WHERE id = %d", (int) $run['id'] ), ARRAY_A );
$after_ctx = json_decode( (string) ( $after['context_json'] ?? '' ), true );
return array(
'run_id' => (int) $run['id'],
'state' => (string) ( $after['state'] ?? '' ),
'delivered' => is_array( $after_ctx ) && ! empty( $after_ctx['_delivered'] ) && empty( $after_ctx['_holdout'] ),
);
}
private static function tester_context( $wf, $wp_user_id, $email ) {
$trigger = (string) ( $wf['trigger_type'] ?? '' );
$ctx = array();
$addr = '' !== $email ? $email : ( $wp_user_id > 0 ? strtolower( (string) get_userdata( $wp_user_id )->user_email ) : '' );
if ( in_array( $trigger, array( 'order_completed', 'guest_created', 'withdrawal_recorded', 'return_approved', 'survey_answered', 'survey_detractor', 'survey_promoter' ), true ) && function_exists( 'wc_get_orders' ) ) {
$q = array( 'type' => 'shop_order', 'limit' => 1, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'ids' );
$oids = $wp_user_id > 0 ? wc_get_orders( $q + array( 'customer' => $wp_user_id ) ) : array();
if ( empty( $oids ) && '' !== $addr ) {
$oids = wc_get_orders( $q + array( 'billing_email' => $addr ) );
}
if ( ! empty( $oids ) ) {
$ctx['order_id'] = (int) $oids[0];
$ctx['bill_email'] = $addr;
$o = wc_get_order( (int) $oids[0] );
if ( $o ) {
$ctx['product_id'] = self::first_product_id( $o );
$ctx['bill_email'] = (string) $o->get_billing_email();
}
}
}
if ( in_array( $trigger, array( 'abandoned_checkout', 'abandoned_cart' ), true ) && class_exists( 'AIEO_Carts_Store' ) ) {
global $wpdb;
$t = AIEO_Carts_Store::carts_table();
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT id, wc_order_id FROM `{$t}` WHERE kind = 'abandoned' AND ( ( %d > 0 AND user_id = %d ) OR customer_email = %s ) ORDER BY id DESC LIMIT 1",
(int) $wp_user_id,
(int) $wp_user_id,
$addr
), ARRAY_A );
if ( $row ) {
$ctx['cart_id'] = (int) $row['id'];
$ctx['order_id'] = (int) $row['wc_order_id'];
}
}
if ( in_array( $trigger, array( 'back_in_stock', 'abandoned_cart' ), true ) && empty( $ctx['product_id'] ) && function_exists( 'wc_get_products' ) ) {
$pids = wc_get_products( array( 'status' => 'publish', 'stock_status' => 'instock', 'limit' => 1, 'return' => 'ids' ) );
if ( ! empty( $pids ) ) {
$ctx['product_id'] = (int) $pids[0];
}
}
if ( 'back_in_stock' === $trigger ) {
$ctx['wl_email'] = $addr;
}
return $ctx;
}
public static function run_manual( $workflow_id ) {
$wf = AIEO_Workflows::get( (int) $workflow_id );
if ( ! $wf ) {
return 0;
}
if ( ! in_array( (string) ( $wf['trigger_type'] ?? '' ), array( 'scheduled', 'manual' ), true ) ) {
return 0;
}
if ( 'active' !== (string) ( $wf['status'] ?? '' ) ) {
return 0;
}
if ( ! self::channels_live( $wf ) ) {
return 0;
}
$n = self::enqueue_audience( $wf );
if ( ! AIEO_Automation_Test_Mode::any_test() || self::reaches_customers( $wf ) ) {
self::mark_run( $wf['id'] );
}
return $n;
}
public static function manual_refusal( $workflow_id ) {
$wf = AIEO_Workflows::get( (int) $workflow_id );
if ( ! $wf ) {
return __( 'Workflow not found.', 'ai-eshop-optimizer' );
}
if ( ! in_array( (string) ( $wf['trigger_type'] ?? '' ), array( 'scheduled', 'manual' ), true ) ) {
return __( 'Only a broadcast can be run now: the other automations start on their own event.', 'ai-eshop-optimizer' );
}
if ( 'active' !== (string) ( $wf['status'] ?? '' ) ) {
return __( 'Activate the workflow first: a draft or paused workflow sends nothing.', 'ai-eshop-optimizer' );
}
if ( ! self::audience_rule( $wf ) ) {
return __( 'Choose an audience first: a broadcast never goes to every customer.', 'ai-eshop-optimizer' );
}
if ( ! self::channels_live( $wf ) ) {
return __( 'No channel can send it: switch e-mail or push to Live, or to Test for the testers.', 'ai-eshop-optimizer' );
}
return __( 'No one in the audience can get it now: the audience is empty, or everyone in it already had it.', 'ai-eshop-optimizer' );
}
private static function enqueue_audience( $wf ) {
$rule = self::audience_rule( $wf );
if ( ! $rule ) {
return 0;
}
$once = ! empty( $wf['trigger_config']['once_per_customer'] );
$n = 0;
$after = 0;
$guard = 0;
$test = AIEO_Automation_Test_Mode::any_test();
$seen = array();
do {
$uids = AIEO_Audience_Rules::member_user_ids( $rule, $after, self::BATCH );
foreach ( $uids as $uid ) {
$uid = (int) $uid;
if ( $uid > $after ) {
$after = $uid;
}
if ( $once && self::has_run_ever( (int) $wf['id'], $uid ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
$ctx = array();
if ( $test && AIEO_Automation_Test_Mode::is_tester( $uid ) ) {
$seen[ $uid ] = true;
$ctx = array( '_test_audience' => 1 );
}
if ( self::enqueue_run( $wf, $uid, $sid, $ctx ) ) {
$n++;
}
}
$guard++;
} while ( count( $uids ) >= self::BATCH && $guard < 500 );
if ( $test ) {
$t = AIEO_Automation_Test_Mode::testers();
foreach ( $t['users'] as $uid ) {
if ( isset( $seen[ $uid ] ) || ( $once && self::has_run_ever( (int) $wf['id'], $uid ) ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
if ( self::enqueue_run( $wf, $uid, $sid, array( '_test_audience' => 0 ) ) ) {
$n++;
}
}
foreach ( $t['emails'] as $email ) {
$u = get_user_by( 'email', $email );
if ( $u ) {
if ( isset( $seen[ (int) $u->ID ] ) || in_array( (int) $u->ID, $t['users'], true ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( (int) $u->ID ) : 0;
$ok = self::enqueue_run( $wf, (int) $u->ID, $sid, array( '_test_audience' => 0 ) );
} else {
$ok = self::enqueue_run( $wf, 0, 0, array( 'guest_email' => $email, '_test_audience' => 0 ), true, md5( $email ) );
}
if ( $ok ) {
$n++;
}
}
}
return $n;
}
private static function fire_abandoned_cart( $wf ) {
global $wpdb;
$a = $wpdb->prefix . 'aieo_addcart_events';
if ( (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $a ) ) !== $a ) {
return;
}
$hours = max( 1, (int) ( $wf['trigger_config']['hours'] ?? 4 ) );
$subj = $wpdb->prefix . 'aieo_subjects';
$coll = self::subjects_collation();
$rule = self::audience_rule( $wf );
$after = 0;
$guard = 0;
do {
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT s.wp_user_id, MIN(a.product_id) AS product_id, MIN(s.subject_id) AS subject_id
FROM `{$a}` a JOIN `{$subj}` s ON s.visitor_key = a.session_id{$coll}
WHERE a.order_id = 0 AND a.product_id > 0 AND s.wp_user_id > %d AND s.merged_into = 0
AND a.ts_added <= (UTC_TIMESTAMP() - INTERVAL %d HOUR)
AND a.ts_added >= (UTC_TIMESTAMP() - INTERVAL 7 DAY)
GROUP BY s.wp_user_id
ORDER BY s.wp_user_id ASC
LIMIT %d",
$after,
$hours,
self::BATCH
), ARRAY_A );
foreach ( (array) $rows as $r ) {
$uid = (int) $r['wp_user_id'];
$pid = (int) $r['product_id'];
if ( $uid > $after ) {
$after = $uid;
}
if ( $rule && ! AIEO_Audience_Rules::matches_user( $rule, $uid ) ) {
continue;
}
if ( self::has_recent_run( (int) $wf['id'], $uid, self::ABANDON_DEDUP_HOURS ) ) {
continue;
}
self::enqueue_run( $wf, $uid, (int) $r['subject_id'], array( 'product_id' => $pid ) );
}
$guard++;
} while ( count( (array) $rows ) >= self::BATCH && $guard < 500 );
}
private static function subjects_collation() {
global $wpdb;
static $c = null;
if ( null === $c ) {
$subj = $wpdb->prefix . 'aieo_subjects';
$col = $wpdb->get_var( $wpdb->prepare(
"SELECT COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'visitor_key' LIMIT 1",
$subj
) );
$c = ( $col && preg_match( '/^[a-z0-9_]+$/', (string) $col ) ) ? ' COLLATE ' . $col : '';
}
return $c;
}
private static function has_recent_run( $wf_id, $uid, $hours ) {
global $wpdb;
$t = aieo_workflow_runs_table();
return (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM `{$t}` WHERE workflow_id = %d AND wp_user_id = %d AND created >= (UTC_TIMESTAMP() - INTERVAL %d HOUR) AND NOT ( state = 'cancelled' AND step_index = 0 )",
(int) $wf_id,
(int) $uid,
max( 1, (int) $hours )
) . self::guard_test_sql() ) > 0;
}
private static function fire_birthday( $wf ) {
$day = 'aieo_wf_bday_' . $wf['id'] . ( AIEO_Automation_Test_Mode::restricts() ? AIEO_Automation_Test_Mode::SUFFIX : '' );
if ( get_transient( $day ) ) {
return;
}
set_transient( $day, 1, DAY_IN_SECONDS );
$days = max( 0, (int) ( $wf['trigger_config']['days_before'] ?? 14 ) );
$target_ts = time() + $days * DAY_IN_SECONDS;
$target = gmdate( 'm-d', $target_ts );
$targets = array( $target );
if ( '03-01' === $target && ! (bool) gmdate( 'L', $target_ts ) ) {
$targets[] = '02-29';
}
global $wpdb;
$in = implode( ',', array_fill( 0, count( $targets ), '%s' ) );
$params = array_merge( $targets, $targets, array( self::BATCH ) );
$uids = $wpdb->get_col( $wpdb->prepare(
"SELECT DISTINCT um.user_id FROM {$wpdb->usermeta} um
WHERE ( um.meta_key = 'aieo_birthday_md' AND um.meta_value IN ({$in}) )
OR ( um.meta_key = '_automatewoo_birthday_md' AND um.meta_value IN ({$in})
AND NOT EXISTS (
SELECT 1 FROM {$wpdb->usermeta} um2
WHERE um2.user_id = um.user_id AND um2.meta_key = 'aieo_birthday_md' AND um2.meta_value <> ''
) )
LIMIT %d",
$params
) );
$rule = self::audience_rule( $wf );
foreach ( (array) $uids as $uid ) {
$uid = (int) $uid;
if ( $rule && ! AIEO_Audience_Rules::matches_user( $rule, $uid ) ) {
continue;
}
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
self::enqueue_run( $wf, $uid, $sid, array() );
}
}
private static function enqueue_run( $wf, $wp_user_id, $subject_id, $context, $skip_user_dedup = false, $email_hash = '' ) {
global $wpdb;
$t = aieo_workflow_runs_table();
$now = gmdate( 'Y-m-d H:i:s' );
if ( AIEO_Automation_Test_Mode::any_test() ) {
$who = (string) ( $context['guest_email'] ?? ( $context['bill_email'] ?? ( $context['wl_email'] ?? '' ) ) );
$tester = AIEO_Automation_Test_Mode::is_tester( (int) $wp_user_id, $who );
if ( $tester ) {
$context['_test'] = 1;
} elseif ( ! self::reaches_customers( $wf ) ) {
return false;
}
}
if ( ! $skip_user_dedup ) {
$open = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM `{$t}` WHERE workflow_id = %d AND wp_user_id = %d AND state IN ('pending','running')",
(int) $wf['id'],
(int) $wp_user_id
) . self::guard_test_sql() );
if ( $open > 0 ) {
return false;
}
}
$ok = $wpdb->insert( $t, array(
'workflow_id' => (int) $wf['id'],
'subject_id' => (int) $subject_id,
'wp_user_id' => (int) $wp_user_id,
'order_id' => (int) ( $context['order_id'] ?? 0 ),
'email_hash' => (string) $email_hash,
'step_index' => 0,
'state' => 'pending',
'context_json' => wp_json_encode( (array) $context ),
'run_at' => $now,
'created' => $now,
'updated' => $now,
) );
if ( false === $ok ) {
return false;
}
self::$last_run_id = (int) $wpdb->insert_id;
if ( ! empty( $context['_test'] ) ) {
return true;
}
$wpdb->query( $wpdb->prepare(
'UPDATE `' . aieo_workflows_table() . '` SET last_run_at = %s WHERE id = %d',
$now,
(int) $wf['id']
) );
return true;
}
private static function enqueue_email_run( $wf, $email, $context, $dedup_hours = 0 ) {
$email = strtolower( sanitize_email( (string) $email ) );
if ( '' === $email || ! is_email( $email ) ) {
return false;
}
$hash = md5( $email );
if ( 0 !== (int) $dedup_hours && self::has_email_run( (int) $wf['id'], $hash, (int) $dedup_hours ) ) {
return false;
}
$uid = 0;
$sid = 0;
$u = get_user_by( 'email', $email );
if ( $u instanceof WP_User ) {
$uid = (int) $u->ID;
$sid = class_exists( 'AIEO_Subject_Resolver' ) ? (int) AIEO_Subject_Resolver::subject_for_user( $uid ) : 0;
}
$context['guest_email'] = $email;
return self::enqueue_run( $wf, $uid, $sid, $context, true, $hash );
}
private static function has_email_run( $wf_id, $email_hash, $within_hours ) {
global $wpdb;
$t = aieo_workflow_runs_table();
if ( (int) $within_hours > 0 ) {
return (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM `{$t}` WHERE workflow_id = %d AND email_hash = %s
AND ( created >= (UTC_TIMESTAMP() - INTERVAL %d HOUR) OR state IN ('pending','running') )
AND NOT ( state = 'cancelled' AND step_index = 0 )",
(int) $wf_id,
(string) $email_hash,
(int) $within_hours
) . self::guard_test_sql() ) > 0;
}
return (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM `{$t}` WHERE workflow_id = %d AND email_hash = %s AND NOT ( state = 'cancelled' AND step_index = 0 )",
(int) $wf_id,
(string) $email_hash
) . self::guard_test_sql() ) > 0;
}
private static function process_runs( $limit ) {
global $wpdb;
$t = aieo_workflow_runs_table();
$now = gmdate( 'Y-m-d H:i:s' );
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM `{$t}` WHERE state IN ('pending','running') AND run_at <= %s ORDER BY run_at ASC LIMIT %d",
$now,
(int) $limit
), ARRAY_A );
if ( empty( $rows ) ) {
return false;
}
foreach ( $rows as $run ) {
self::process_run( $run );
}
return count( $rows ) >= $limit;
}
private static function process_run( $run ) {
global $wpdb;
$now = $wpdb->get_row( $wpdb->prepare( 'SELECT state, step_index, context_json FROM `' . aieo_workflow_runs_table() . '` WHERE id = %d', (int) $run['id'] ), ARRAY_A );
if ( ! $now || ! in_array( (string) $now['state'], array( 'pending', 'running' ), true ) ) {
return;
}
$run = array_merge( $run, $now );
$ctx = json_decode( (string) $run['context_json'], true ) ?: array();
if ( ! empty( $ctx['_test'] ) ) {
AIEO_Automation_Test_Mode::enter();
try {
self::process_run_steps( $run, $ctx );
} finally {
AIEO_Automation_Test_Mode::leave();
}
return;
}
self::process_run_steps( $run, $ctx );
}
private static function process_run_steps( $run, $ctx ) {
global $wpdb;
$t = aieo_workflow_runs_table();
$wf = AIEO_Workflows::get( (int) $run['workflow_id'] );
if ( ! $wf || ( 'active' !== $wf['status'] && empty( $ctx['_test'] ) ) ) {
$si = ( empty( $ctx['_delivered'] ) && empty( $ctx['_acted'] ) ) ? 0 : (int) $run['step_index'];
$wpdb->update( $t, array( 'state' => 'cancelled', 'step_index' => $si, 'updated' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => (int) $run['id'] ) );
return;
}
$steps = (array) $wf['steps'];
$i = (int) $run['step_index'];
while ( $i < count( $steps ) ) {
$step = $steps[ $i ];
$type = (string) ( $step['type'] ?? '' );
$asking = false;
if ( 'ask_survey' === $type ) {
if ( ! class_exists( 'AIEO_Survey_Ask' ) ) {
$i++;
continue;
}
$asked = AIEO_Survey_Ask::prepare( $step, $wf, $run, $ctx );
if ( ! $asked ) {
$i++;
continue;
}
$ctx['_aieo_survey'] = $asked['survey'];
$step = $asked['step'];
$type = 'send';
$asking = true;
}
if ( 'wait' === $type ) {
$secs = ( 'hours' === ( $step['unit'] ?? 'days' ) ? HOUR_IN_SECONDS : DAY_IN_SECONDS ) * max( 0, (float) ( $step['value'] ?? 0 ) );
$run_at = gmdate( 'Y-m-d H:i:s', time() + (int) $secs );
$wpdb->update( $t, array( 'step_index' => $i + 1, 'run_at' => $run_at, 'state' => 'pending', 'context_json' => wp_json_encode( $ctx ), 'updated' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => (int) $run['id'] ) );
return;
}
if ( 'send' === $type ) {
$tt = (string) ( $wf['trigger_type'] ?? '' );
$recheck_on = ! empty( $wf['trigger_config']['recheck'] )
|| ( 'order_completed' === $tt && 'checkout-draft' === self::order_trigger_status( $wf ) )
|| 'abandoned_checkout' === $tt;
$recheck_fail = $recheck_on && (
( 'order_completed' === $tt && ! self::order_still_qualifies( $ctx, $wf ) )
|| ( 'abandoned_checkout' === $tt && ! self::cart_still_abandoned( $ctx ) )
);
if ( $recheck_fail ) {
$ctx['_recheck_failed'] = 1;
$wpdb->update( $t, array( 'state' => 'cancelled', 'step_index' => 0, 'context_json' => wp_json_encode( $ctx ), 'updated' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => (int) $run['id'] ) );
return;
}
if ( ! empty( $wf['trigger_config']['optimize_send_time'] ) && empty( $ctx['_std_done'] ) && class_exists( 'AIEO_Send_Time' ) ) {
$std_ch = (string) ( $step['channel'] ?? 'cascade' );
$std_live = ( 'email' === $std_ch ) ? self::channel_live( 'email' )
: ( ( 'push' === $std_ch ) ? self::channel_live( 'push' ) : ( self::channel_live( 'email' ) || self::channel_live( 'push' ) ) );
if ( $std_live ) {
$best = AIEO_Send_Time::best_hour( (int) $run['wp_user_id'], (string) ( $ctx['guest_email'] ?? '' ) );
if ( null !== $best ) {
$ctx['_std_done'] = 1;
$delay = (int) AIEO_Send_Time::delay_to_hour( $best );
if ( $delay > 0 ) {
$wpdb->update( $t, array(
'state' => 'pending',
'step_index' => $i,
'run_at' => gmdate( 'Y-m-d H:i:s', time() + $delay ),
'context_json' => wp_json_encode( $ctx ),
'updated' => gmdate( 'Y-m-d H:i:s' ),
), array( 'id' => (int) $run['id'] ) );
return;
}
}
}
}
if ( ! empty( $ctx['_awaits_consent'] ) && 'email' === (string) ( $step['channel'] ?? 'cascade' ) && class_exists( 'AIEO_Email_Channel' )
&& '' === AIEO_Email_Channel::resolve_recipient( (int) $run['subject_id'], (int) $run['wp_user_id'] ) ) {
$outcome = 'terminal';
self::$send_grant = 0;
self::$send_reason = 'no_consented_email';
} else {
$outcome = self::do_send( $step, $wf, $run, $ctx, $i );
}
if ( 'terminal' === $outcome && in_array( self::$send_reason, array( 'no_consented_email', 'no_consent' ), true )
&& 'welcome' === (string) ( $wf['trigger_type'] ?? '' ) && empty( $ctx['_delivered'] ) && (int) $run['wp_user_id'] > 0 ) {
$u = get_userdata( (int) $run['wp_user_id'] );
$until = ( $u ? (int) strtotime( $u->user_registered . ' UTC' ) : 0 ) + max( 1, (int) ( $wf['trigger_config']['hours'] ?? 48 ) ) * HOUR_IN_SECONDS;
$now = gmdate( 'Y-m-d H:i:s' );
self::withdraw_send_coupon();
if ( time() < $until ) {
$ctx['_awaits_consent'] = 1;
$wpdb->update( $t, array( 'state' => 'pending', 'step_index' => $i, 'run_at' => gmdate( 'Y-m-d H:i:s', min( $until, time() + HOUR_IN_SECONDS ) ), 'context_json' => wp_json_encode( $ctx ), 'updated' => $now ), array( 'id' => (int) $run['id'] ) );
return;
}
self::withdraw_send_coupon();
$wpdb->update( $t, array( 'state' => 'cancelled', 'step_index' => 0, 'context_json' => wp_json_encode( $ctx ), 'updated' => $now ), array( 'id' => (int) $run['id'] ) );
return;
}
unset( $ctx['_awaits_consent'] );
if ( $asking && 'sent' !== $outcome ) {
unset( $ctx['_aieo_survey'] );
}
self::note_send_outcome( $outcome, $wf, $ctx );
if ( 'paused' === $outcome ) {
$ctx['_held'] = self::carrier( $step, $wf, $run, $ctx );
} else {
unset( $ctx['_held'] );
}
$guarded = self::is_guarded_trigger( $wf );
$reschedule = ( 'retry' === $outcome ) || ( 'paused' === $outcome && $guarded );
if ( 'paused' === $outcome && ! $guarded ) {
self::withdraw_send_coupon();
$si = ( empty( $ctx['_delivered'] ) && empty( $ctx['_acted'] ) ) ? 0 : $i;
$wpdb->update( $t, array( 'state' => 'cancelled', 'step_index' => $si, 'context_json' => wp_json_encode( $ctx ), 'updated' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => (int) $run['id'] ) );
return;
}
if ( $reschedule ) {
if ( 'retry' === $outcome ) {
$key = '_send_retries_' . $i;
$retries = (int) ( $ctx[ $key ] ?? 0 ) + 1;
if ( $retries > self::MAX_SEND_RETRIES ) {
$reschedule = false;
} else {
$ctx[ $key ] = $retries;
}
}
if ( $reschedule ) {
$wpdb->update( $t, array(
'state' => 'pending',
'step_index' => $i,
'run_at' => gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ),
'context_json' => wp_json_encode( $ctx ),
'updated' => gmdate( 'Y-m-d H:i:s' ),
), array( 'id' => (int) $run['id'] ) );
return;
}
}
if ( ! in_array( $outcome, array( 'sent', 'holdout' ), true ) ) {
self::withdraw_send_coupon();
}
self::stamp_guard_on_terminal( $wf, $run, $ctx );
}
if ( ( 'custom_function' === $type || in_array( $type, self::MUTATION_STEPS, true ) ) && ! empty( $ctx['_test'] ) ) {
$ctx['_test_skipped'] = (array) ( $ctx['_test_skipped'] ?? array() );
$ctx['_test_skipped'][] = self::would_do( $type, $step, $ctx, $i );
} elseif ( 'custom_function' === $type ) {
self::do_custom_function( $step, $wf, $run, $ctx );
$ctx['_acted'] = 1;
} elseif ( in_array( $type, self::MUTATION_STEPS, true ) ) {
self::do_mutation( $type, $step, $wf, $run, $ctx );
$ctx['_acted'] = 1;
}
$i++;
}
$wpdb->update( $t, array( 'state' => 'done', 'step_index' => $i, 'context_json' => wp_json_encode( $ctx ), 'updated' => gmdate( 'Y-m-d H:i:s' ) ), array( 'id' => (int) $run['id'] ) );
}
private static function note_send_outcome( $outcome, $wf, array &$ctx ) {
if ( 'sent' === $outcome ) {
$ctx['_delivered'] = 1;
if ( empty( $ctx['_reached'] ) && empty( $ctx['_test'] ) ) {
$ctx['_reached'] = 1;
global $wpdb;
$wpdb->query( $wpdb->prepare(
'UPDATE `' . aieo_workflows_table() . '` SET runs_count = runs_count + 1 WHERE id = %d',
(int) $wf['id']
) );
}
} elseif ( 'holdout' === $outcome ) {
$ctx['_delivered'] = 1;
$ctx['_holdout'] = 1;
}
}
private static function reaches_customers( $wf ) {
if ( AIEO_Automation_Test_Mode::restricts() ) {
return false;
}
$email = AIEO_Automation_Test_Mode::engine_live( 'email' );
$push = AIEO_Automation_Test_Mode::engine_live( 'push' );
$trigger = (string) ( $wf['trigger_type'] ?? '' );
$sends = false;
foreach ( (array) ( $wf['steps'] ?? array() ) as $s ) {
if ( ! in_array( (string) ( $s['type'] ?? '' ), array( 'send', 'ask_survey' ), true ) ) {
continue;
}
$sends = true;
$ch = in_array( $trigger, array_merge( array( 'order_completed', 'back_in_stock' ), self::SERVICE_TRIGGERS ), true ) ? 'email' : (string) ( $s['channel'] ?? 'cascade' );
if ( ( 'email' === $ch && $email ) || ( 'push' === $ch && $push ) || ( 'email' !== $ch && 'push' !== $ch && ( $email || $push ) ) ) {
return true;
}
}
return ! $sends;
}
private static function carrier( $step, $wf, $run, $ctx ) {
$trigger = (string) ( $wf['trigger_type'] ?? '' );
$channel = (string) ( $step['channel'] ?? 'cascade' );
if ( 'order_completed' === $trigger || ( 'back_in_stock' === $trigger && (int) ( $ctx['wl_id'] ?? 0 ) > 0 ) || in_array( $trigger, self::SERVICE_TRIGGERS, true ) ) {
return 'email';
}
if ( (int) $run['wp_user_id'] <= 0 && '' !== (string) ( $ctx['guest_email'] ?? '' ) ) {
return 'email' === $channel ? 'email' : 'cascade';
}
if ( 'cascade' === $channel && (int) $run['subject_id'] <= 0 ) {
return 'email';
}
return in_array( $channel, array( 'email', 'push' ), true ) ? $channel : 'cascade';
}
public static function mark_held_runs() {
global $wpdb;
if ( ! self::ready() ) {
return -1;
}
$t = aieo_workflow_runs_table();
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM `{$t}` WHERE state = 'pending' AND TIMESTAMPDIFF( SECOND, updated, run_at ) BETWEEN 3540 AND 3660
AND ( context_json IS NULL OR ( context_json NOT LIKE %s AND context_json NOT LIKE %s ) )
ORDER BY id ASC LIMIT 20000",
'%' . $wpdb->esc_like( '"_test":1' ) . '%',
'%' . $wpdb->esc_like( '"_held":' ) . '%'
), ARRAY_A );
$live = array(
'email' => self::channel_live( 'email' ),
'push' => self::channel_live( 'push' ),
);
$n = 0;
$wfs = array();
foreach ( (array) $rows as $run ) {
$wid = (int) $run['workflow_id'];
if ( ! array_key_exists( $wid, $wfs ) ) {
$wfs[ $wid ] = AIEO_Workflows::get( $wid );
}
$wf = $wfs[ $wid ];
$step = $wf ? ( $wf['steps'][ (int) $run['step_index'] ] ?? null ) : null;
if ( ! is_array( $step ) || ! in_array( (string) ( $step['type'] ?? '' ), array( 'send', 'ask_survey' ), true ) ) {
continue;
}
$ctx = json_decode( (string) $run['context_json'], true ) ?: array();
$carrier = self::carrier( $step, $wf, $run, $ctx );
$closed = ( 'cascade' === $carrier ) ? ( ! $live['email'] && ! $live['push'] ) : ! $live[ $carrier ];
if ( ! $closed ) {
continue;
}
$ctx['_held'] = $carrier;
$n += (int) $wpdb->update( $t, array( 'context_json' => wp_json_encode( $ctx ) ), array( 'id' => (int) $run['id'] ) );
}
return $n;
}
private static function is_guarded_trigger( $wf ) {
return in_array( (string) ( $wf['trigger_type'] ?? '' ), array( 'welcome', 'birthday', 'back_in_stock', 'win_back', 'abandoned_cart', 'order_completed', 'opted_in', 'opted_out', 'guest_created', 'abandoned_checkout', 'survey_detractor', 'survey_promoter', 'survey_answered', 'withdrawal_recorded', 'return_approved' ), true );
}
private static function do_custom_function( $step, $wf, $run, $ctx ) {
$fn = trim( (string) ( $step['function_name'] ?? '' ) );
if ( '' === $fn ) {
return;
}
$payload = array(
'function' => $fn,
'workflow' => $wf,
'run' => $run,
'context' => $ctx,
'order_id' => (int) ( $ctx['order_id'] ?? 0 ),
'wp_user_id' => (int) ( $run['wp_user_id'] ?? 0 ),
'subject_id' => (int) ( $run['subject_id'] ?? 0 ),
);
do_action( 'aieo_workflow_custom_function', $fn, $payload );
do_action( 'aieo_workflow_custom_function_' . $fn, $payload );
if ( function_exists( $fn ) && (bool) apply_filters( 'aieo_workflow_allow_custom_function', false, $fn, $payload ) ) {
call_user_func( $fn, $payload );
}
}
private static function do_mutation( $type, $step, $wf, $run, $ctx ) {
$oid = (int) ( $ctx['order_id'] ?? 0 );
$uid = (int) ( $run['wp_user_id'] ?? 0 );
$order = ( $oid > 0 && function_exists( 'wc_get_order' ) ) ? wc_get_order( $oid ) : null;
if ( ! $order instanceof WC_Order ) {
$order = null;
}
if ( $uid <= 0 && $order ) {
$uid = (int) $order->get_customer_id();
}
switch ( $type ) {
case 'change_order_status':
if ( ! $order ) {
return;
}
$to = sanitize_key( (string) ( $step['order_status'] ?? '' ) );
$allowed = array( 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'pending', 'failed' );
if ( ! in_array( $to, $allowed, true ) || $order->get_status() === $to ) {
return;
}
$note = trim( (string) ( $step['note'] ?? '' ) );
$order->update_status( $to, ( '' !== $note ? $note . ' ' : '' ) . '[AIEO workflow]', false );
break;
case 'order_add_note':
if ( ! $order ) {
return;
}
$note = trim( (string) ( $step['note'] ?? '' ) );
if ( '' === $note ) {
return;
}
$order->add_order_note( $note, empty( $step['customer_note'] ) ? 0 : 1, false );
break;
case 'customer_change_role':
$role = sanitize_key( (string) ( $step['role'] ?? '' ) );
$mode = (string) ( $step['mode'] ?? 'replace' );
if ( $uid <= 0 || ! class_exists( 'AIEO_Safe_Roles' ) || ! AIEO_Safe_Roles::is_safe( $role ) || ! AIEO_Safe_Roles::is_customer_account( $uid ) ) {
return;
}
if ( 'remove' === $mode ) {
AIEO_Safe_Roles::revoke( $uid, $role, 'workflow' );
break;
}
AIEO_Safe_Roles::grant( $uid, $role, 'workflow' );
if ( 'replace' === $mode ) {
$u = get_userdata( $uid );
foreach ( $u ? (array) $u->roles : array() as $other ) {
if ( $other !== $role ) {
AIEO_Safe_Roles::revoke( $uid, (string) $other, 'workflow' );
}
}
}
break;
case 'order_update_meta':
if ( ! $order ) {
return;
}
$k = self::clean_meta_key( $step['meta_key'] ?? '' );
if ( '' === $k ) {
return;
}
$order->update_meta_data( $k, self::meta_step_value( $step, $wf, $run, $ctx ) );
$order->save();
break;
case 'customer_update_meta':
$k = self::clean_meta_key( $step['meta_key'] ?? '' );
if ( $uid <= 0 || '' === $k || self::is_blocked_user_meta( $k ) ) {
return;
}
update_user_meta( $uid, $k, self::meta_step_value( $step, $wf, $run, $ctx ) );
break;
}
}
private static function would_do( $type, $step, $ctx, $i ) {
$what = '';
switch ( $type ) {
case 'change_order_status':
$what = sanitize_key( (string) ( $step['order_status'] ?? '' ) );
break;
case 'customer_change_role':
$what = sanitize_key( (string) ( $step['role'] ?? '' ) );
break;
case 'order_update_meta':
case 'customer_update_meta':
$what = self::clean_meta_key( $step['meta_key'] ?? '' );
break;
case 'custom_function':
$what = sanitize_text_field( (string) ( $step['function_name'] ?? '' ) );
break;
}
return array(
'i' => (int) $i,
'type' => (string) $type,
'order' => (int) ( $ctx['order_id'] ?? 0 ),
'what' => $what,
'mode' => 'customer_change_role' === $type ? sanitize_key( (string) ( $step['mode'] ?? 'replace' ) ) : '',
);
}
private static function meta_step_value( $step, $wf, $run, $ctx ) {
$v = (string) ( $step['meta_value'] ?? '' );
if ( class_exists( 'AIEO_Merge_Tags' ) && false !== strpos( $v, '{{' ) ) {
$known = array_flip( AIEO_Merge_Tags::CATALOG );
$out = preg_replace_callback(
'/\{\{\s*([a-z0-9_.]+)[^}]*\}\}/u',
static function ( $m ) use ( $known, $run, $ctx, $wf, $step ) {
return isset( $known[ $m[1] ] ) ? AIEO_Merge_Tags::resolve( $m[0], $run, $ctx, $wf, $step, null, array(), 'text' ) : $m[0];
},
$v
);
$v = null === $out ? $v : (string) $out;
}
return sanitize_text_field( $v );
}
private static function clean_meta_key( $k ) {
$k = trim( (string) $k );
return ( '' !== $k && preg_match( '/^[A-Za-z0-9_\-]+$/', $k ) ) ? $k : '';
}
private static function is_blocked_user_meta( $k ) {
global $wpdb;
$lk = strtolower( $k );
if ( preg_match( '/(^|_)capabilities$|(^|_)user_level$|session_tokens|user-settings/', $lk ) ) {
return true;
}
foreach ( array( $wpdb->prefix . 'capabilities', $wpdb->prefix . 'user_level', 'wp_capabilities', 'wp_user_level' ) as $bad ) {
if ( $lk === strtolower( $bad ) ) {
return true;
}
}
return function_exists( 'is_protected_meta' ) && is_protected_meta( $k, 'user' );
}
private static function stamp_guard_on_terminal( $wf, $run, $ctx ) {
if ( 'win_back' === (string) ( $wf['trigger_type'] ?? '' ) && '' !== (string) ( $ctx['winback_meta_key'] ?? '' ) ) {
$key = (string) $ctx['winback_meta_key'] . ( empty( $ctx['_test'] ) ? '' : AIEO_Automation_Test_Mode::SUFFIX );
update_user_meta( (int) $run['wp_user_id'], $key, (string) ( $ctx['cohort'] ?? '' ) );
}
}
private static function do_send( $step, $wf, $run, $ctx, $i = 0 ) {
self::$send_grant = 0;
self::$send_reason = '';
if ( AIEO_Automation_Test_Mode::in_scope() ) {
$ctx = self::tester_addresses( $run, $ctx );
}
if ( ! empty( $ctx['order_id'] ) && function_exists( 'wc_get_order' ) ) {
$ctx_order = wc_get_order( (int) $ctx['order_id'] );
if ( $ctx_order && apply_filters( 'aieo_suppress_order_customer_email', false, $ctx_order, 'workflow' ) ) {
return 'terminal';
}
}
$subject_id = (int) $run['subject_id'];
$trigger_id = AIEO_Automation_Test_Mode::trigger_id( (int) $wf['id'] );
$is_test = AIEO_Automation_Test_Mode::in_scope();
$subject = (string) ( $step['subject'] ?? '' );
$body = (string) ( $step['body'] ?? '' );
$product_id = 0;
$products = array();
$src = (string) ( $step['product_source'] ?? 'none' );
if ( 'trigger_product' === $src && ! empty( $ctx['product_id'] ) ) {
$product_id = (int) $ctx['product_id'];
$products[] = $product_id;
} elseif ( 'recommendations' === $src && $subject_id > 0 && class_exists( 'AIEO_Behavioural_Reco' ) ) {
foreach ( AIEO_Behavioural_Reco::recommend( $subject_id, 4 ) as $r ) {
$products[] = (int) $r['item_id'];
}
$product_id = $products[0] ?? 0;
}
$channel = (string) ( $step['channel'] ?? 'cascade' );
$trigger_type = (string) ( $wf['trigger_type'] ?? '' );
$is_order = ( 'order_completed' === $trigger_type );
$service = in_array( $trigger_type, self::SERVICE_TRIGGERS, true );
$service_to = $service ? self::service_address( $run, $ctx ) : '';
$guest_addr = ( (int) $run['wp_user_id'] <= 0 ) ? (string) ( $ctx['guest_email'] ?? '' ) : '';
$is_guest_mail = ( '' !== $guest_addr );
$carrier_live = ( $is_order || $service )
? self::channel_live( 'email' )
: ( $is_guest_mail
? ( self::channel_live( 'email' ) || self::channel_live( 'push' ) )
: self::channel_live( $channel ) );
$holdout_pct = (int) ( $wf['trigger_config']['holdout_pct'] ?? 0 );
if ( $carrier_live && $holdout_pct > 0 && ! $is_test && ! $is_order && ! $service && 'back_in_stock' !== $trigger_type ) {
$hkey = ( (int) $run['wp_user_id'] > 0 )
? ( 'u' . (int) $run['wp_user_id'] )
: ( 'e' . strtolower( trim( (string) ( $ctx['guest_email'] ?? '' ) ) ) );
if ( 'e' !== $hkey && self::in_holdout( (int) $wf['id'], $hkey, $holdout_pct ) ) {
self::log_holdout( $trigger_id, $run, $ctx );
return 'holdout';
}
}
$copy_subj_field = 'subject';
$copy_body_field = 'body';
if ( class_exists( 'AIEO_Workflow_AB' ) ) {
$ab_variants = AIEO_Workflow_AB::variants( $step );
if ( count( $ab_variants ) > 1 ) {
$vi = $is_test
? ( (int) ( $run['id'] ?? 0 ) % count( $ab_variants ) )
: AIEO_Workflow_AB::choose( (int) $wf['id'], (int) $run['step_index'], (int) $run['wp_user_id'], (string) ( $ctx['guest_email'] ?? '' ), count( $ab_variants ) );
$subject = (string) ( $ab_variants[ $vi ]['subject'] ?? $subject );
$body = (string) ( $ab_variants[ $vi ]['body'] ?? $body );
if ( 1 === (int) $vi ) {
$copy_subj_field = 'variant_subject';
$copy_body_field = 'variant_body';
}
}
}
if ( class_exists( 'AIEO_Campaign_I18n' ) && AIEO_Campaign_I18n::is_active() ) {
$wpml_lang = AIEO_Campaign_I18n::recipient_language_code( (int) $run['wp_user_id'], $guest_addr, $ctx );
$subject = AIEO_Campaign_I18n::translate( (int) $wf['id'], (int) $i, $copy_subj_field, $subject, $wpml_lang );
$body = AIEO_Campaign_I18n::translate( (int) $wf['id'], (int) $i, $copy_body_field, $body, $wpml_lang );
}
$coupon = null;
if ( class_exists( 'AIEO_Coupon_Minter' ) && 'none' !== (string) ( $step['offer'] ?? 'none' ) && 'opted_out' !== $trigger_type
&& ( (int) $run['wp_user_id'] > 0 || $is_order || $is_guest_mail ) && $carrier_live ) {
$addr = ( 'back_in_stock' === $trigger_type )
? (string) ( $ctx['wl_email'] ?? '' )
: ( $is_order
? (string) ( $ctx['bill_email'] ?? '' )
: ( $is_guest_mail
? $guest_addr
: ( class_exists( 'AIEO_Email_Channel' ) ? AIEO_Email_Channel::resolve_recipient( $subject_id, (int) $run['wp_user_id'] ) : '' ) ) );
if ( $service ) {
$addr = $service_to;
}
$coupon_source = $is_test
? $trigger_id
: ( ( $is_order && ! empty( $ctx['order_id'] ) )
? $trigger_id . '_o' . (int) $ctx['order_id']
: ( $is_guest_mail ? $trigger_id . '_g' . md5( $guest_addr ) : $trigger_id ) );
$coupon = AIEO_Coupon_Minter::mint( array(
'offer_type' => (string) $step['offer'],
'offer_value' => (float) ( $step['offer_value'] ?? 0 ),
'ttl_days' => max( 1, (int) ( $step['offer_ttl_days'] ?? 14 ) ),
'wp_user_id' => (int) $run['wp_user_id'],
'subject_id' => $subject_id,
'email' => $addr,
'source' => $coupon_source,
'channel' => ( $is_order || $service ) ? 'email' : substr( $channel, 0, 10 ),
) );
if ( $coupon ) {
self::$send_grant = (int) $coupon['grant_id'];
$coupon['url'] = self::tag_url( AIEO_Coupon_Minter::offer_url( $coupon, (string) $addr ), 'email', $wf );
}
}
if ( class_exists( 'AIEO_Merge_Tags' ) ) {
$subject = AIEO_Merge_Tags::resolve( $subject, $run, $ctx, $wf, $step, $coupon, $products, 'text' );
$body = AIEO_Merge_Tags::resolve( $body, $run, $ctx, $wf, $step, $coupon, $products, 'html' );
}
$html = ( class_exists( 'AIEO_I18n' ) )
? AIEO_I18n::with_locale(
AIEO_I18n::recipient_locale( (int) $run['wp_user_id'], $guest_addr, $ctx ),
function () use ( $subject, $body, $products, $step, $coupon, $wf ) {
return self::render_email( $subject, $body, $products, $step, $coupon, $wf );
}
)
: self::render_email( $subject, $body, $products, $step, $coupon, $wf );
$push = array(
'title' => $subject !== '' ? $subject : get_bloginfo( 'name' ),
'body' => self::push_text( $body, $coupon ),
'url' => self::tag_url( $product_id > 0 ? (string) get_permalink( $product_id ) : home_url( '/' ), 'push', $wf ),
);
if ( ! class_exists( 'AIEO_Channel_Cascade' ) || ! class_exists( 'AIEO_Email_Channel' ) ) {
return 'terminal';
}
if ( 'back_in_stock' === $trigger_type && (int) ( $ctx['wl_id'] ?? 0 ) > 0 ) {
return self::dispatch_back_in_stock( $ctx, $trigger_id, $subject, $html, $coupon );
}
if ( $is_order ) {
return self::dispatch_order_completed( $ctx, $trigger_id, $subject, $html, $product_id, $coupon );
}
if ( $service ) {
return self::dispatch_transactional_email( $service_to, $trigger_id, $subject, $html, $product_id, $coupon, false, 'opted_out' === $trigger_type && empty( $ctx['_delivered'] ) );
}
$dedup = self::dedup_args( $wf, $i );
$guest_email = (string) ( $ctx['guest_email'] ?? '' );
if ( '' !== $guest_email && (int) $run['wp_user_id'] <= 0 ) {
if ( 'email' !== $channel && class_exists( 'AIEO_Channel_Cascade' ) && method_exists( 'AIEO_Channel_Cascade', 'dispatch_guest' ) ) {
$gp = AIEO_Channel_Cascade::dispatch_guest( array(
'trigger_id' => $trigger_id,
'guest_email' => $guest_email,
'target_product_id' => $product_id,
'push' => $push,
) + $dedup );
if ( 'push' === ( $gp['channel'] ?? '' ) ) {
if ( $coupon ) {
AIEO_Coupon_Minter::mark_sent( (int) $coupon['grant_id'], 'push' );
}
return 'sent';
}
if ( 'push' === $channel ) {
return 'terminal';
}
}
return self::dispatch_transactional_email( $guest_email, $trigger_id, $subject, $html, $product_id, $coupon, true );
}
$args = array(
'trigger_id' => $trigger_id,
'subject_id' => $subject_id,
'push' => $push,
'email' => ( 'push' === $channel ) ? array() : array( 'subject' => $subject, 'html' => $html ),
'target_product_id' => $product_id,
) + $dedup;
$sent = false;
$reason = '';
$ch = 'email';
if ( 'email' === $channel || ( 'cascade' === $channel && $subject_id <= 0 ) ) {
$addr = AIEO_Email_Channel::resolve_recipient( $subject_id, (int) $run['wp_user_id'] );
if ( '' === $addr ) {
$reason = 'no_consented_email';
} else {
$res = AIEO_Email_Channel::dispatch( array(
'trigger_id' => $trigger_id, 'user_id' => (int) $run['wp_user_id'], 'subject_id' => $subject_id,
'email' => $addr, 'subject' => $subject, 'html' => $html, 'target_product_id' => $product_id,
) + $dedup );
$sent = ! empty( $res['sent'] );
$reason = (string) ( $res['reason'] ?? '' );
}
} else {
$res = AIEO_Channel_Cascade::dispatch( $args );
$ch = (string) ( $res['channel'] ?? 'email' );
$reason = (string) ( $res['reason'] ?? '' );
$sent = ( 'push' === $ch && 'push_sent_fallback_scheduled' === $reason )
|| ( 'email' === $ch && 'sent' === $reason );
}
if ( $sent && $coupon ) {
AIEO_Coupon_Minter::mark_sent( (int) $coupon['grant_id'], ( 'push' === $ch ) ? 'push' : 'email' );
}
return self::classify_outcome( $sent, $reason );
}
private static function withdraw_send_coupon() {
if ( self::$send_grant > 0 && class_exists( 'AIEO_Coupon_Minter' ) && method_exists( 'AIEO_Coupon_Minter', 'withdraw' ) ) {
AIEO_Coupon_Minter::withdraw( self::$send_grant );
}
self::$send_grant = 0;
}
private static function classify_outcome( $sent, $reason ) {
self::$send_reason = $sent ? '' : (string) $reason;
if ( $sent ) {
return 'sent';
}
if ( 'engine_paused' === $reason ) {
return 'paused';
}
if ( in_array( $reason, array( 'deduped', 'no_consented_email', 'no_consent', 'aw_unsub', 'no_subject', 'no_email_payload', 'not_tester' ), true ) ) {
return 'terminal';
}
return 'retry';
}
private static function dispatch_back_in_stock( $ctx, $trigger_id, $subject, $html, $coupon ) {
global $wpdb;
$wl = $wpdb->prefix . 'aieo_waitlist';
$wl_id = (int) $ctx['wl_id'];
$email = (string) ( $ctx['wl_email'] ?? '' );
$dedup_pid = (int) ( $ctx['product_id'] ?? 0 );
if ( '' === $email || ! is_email( $email ) ) {
return 'terminal';
}
if ( $dedup_pid > 0 && function_exists( 'wc_get_product' ) ) {
$wp = wc_get_product( $dedup_pid );
if ( ! $wp || 'instock' !== $wp->get_stock_status() || 'publish' !== $wp->get_status()
|| ( $wp->get_parent_id() > 0 && 'publish' !== get_post_status( $wp->get_parent_id() ) ) ) {
return 'terminal';
}
}
$claimed = (int) $wpdb->query( $wpdb->prepare(
"UPDATE `{$wl}` SET status = 'notified', notified_at = UTC_TIMESTAMP() WHERE id = %d AND status = 'subscribed' AND notified_at IS NULL",
$wl_id
) );
if ( $claimed < 1 ) {
return 'terminal';
}
$res = AIEO_Email_Channel::dispatch( array(
'trigger_id' => $trigger_id, 'user_id' => 0, 'subject_id' => 0, 'skip_dedup' => true,
'email' => $email, 'subject' => $subject, 'html' => $html, 'target_product_id' => $dedup_pid,
) );
$sent = ! empty( $res['sent'] );
$reason = (string) ( $res['reason'] ?? '' );
if ( $sent ) {
if ( $coupon ) {
AIEO_Coupon_Minter::mark_sent( (int) $coupon['grant_id'], 'email' );
}
return 'sent';
}
if ( 'aw_unsub' === $reason ) {
$wpdb->query( $wpdb->prepare(
"UPDATE `{$wl}` SET status = 'suppressed' WHERE id = %d",
$wl_id
) );
return 'terminal';
}
$wpdb->query( $wpdb->prepare(
"UPDATE `{$wl}` SET status = 'subscribed', notified_at = NULL WHERE id = %d",
$wl_id
) );
if ( 'engine_paused' === $reason ) {
return 'paused';
}
return 'retry';
}
private static function dispatch_order_completed( $ctx, $trigger_id, $subject, $html, $product_id, $coupon ) {
return self::dispatch_transactional_email( (string) ( $ctx['bill_email'] ?? '' ), $trigger_id, $subject, $html, $product_id, $coupon );
}
private static function dispatch_transactional_email( $email, $trigger_id, $subject, $html, $product_id, $coupon, $marketing = false, $confirms_opt_out = false ) {
$email = (string) $email;
if ( '' === $email || ! is_email( $email ) ) {
return 'terminal';
}
if ( ! class_exists( 'AIEO_Email_Channel' ) ) {
return 'terminal';
}
$res = AIEO_Email_Channel::dispatch( array(
'trigger_id' => $trigger_id, 'user_id' => 0, 'subject_id' => 0, 'skip_dedup' => true,
'email' => $email, 'subject' => $subject, 'html' => $html, 'target_product_id' => (int) $product_id,
'marketing' => (bool) $marketing,
) + ( $confirms_opt_out ? array( 'confirms_opt_out' => true ) : array() ) );
$sent = ! empty( $res['sent'] );
$reason = (string) ( $res['reason'] ?? '' );
if ( $sent && $coupon ) {
AIEO_Coupon_Minter::mark_sent( (int) $coupon['grant_id'], 'email' );
}
return self::classify_outcome( $sent, $reason );
}
private static function tag_url( $url, $medium, $wf ) {
$url = (string) $url;
if ( '' === $url ) {
return $url;
}
$medium = ( 'push' === $medium ) ? 'push' : 'email';
$cfg = is_array( $wf['trigger_config'] ?? null ) ? $wf['trigger_config'] : array();
$ov = static function ( $key, $default ) use ( $cfg ) {
$v = isset( $cfg[ $key ] ) ? trim( (string) $cfg[ $key ] ) : '';
return '' !== $v ? $v : $default;
};
$name = trim( wp_strip_all_tags( (string) ( $wf['name'] ?? '' ) ) );
$name40 = ( '' !== $name ) ? ( function_exists( 'mb_substr' ) ? mb_substr( $name, 0, 40 ) : substr( $name, 0, 40 ) ) : '';
$args = array(
'utm_source' => $ov( 'utm_source', 'aieo' ),
'utm_medium' => $ov( 'utm_medium', $medium ),
'utm_campaign' => AIEO_Automation_Test_Mode::in_scope() ? AIEO_Automation_Test_Mode::trigger_id( (int) ( $wf['id'] ?? 0 ) ) : $ov( 'utm_campaign', 'wf_' . (int) ( $wf['id'] ?? 0 ) ),
);
$content = $ov( 'utm_content', $name40 );
if ( '' !== $content ) {
$args['utm_content'] = $content;
}
$term = $ov( 'utm_term', '' );
if ( '' !== $term ) {
$args['utm_term'] = $term;
}
if ( 'push' === $medium ) {
$args['aieo_src'] = 'push';
}
return add_query_arg( $args, $url );
}
private static function in_holdout( $wf_id, $key, $pct ) {
$pct = max( 0, min( 90, (int) $pct ) );
if ( $pct <= 0 ) {
return false;
}
$bucket = hexdec( substr( md5( 'aieo_holdout|' . (int) $wf_id . '|' . $key ), 0, 8 ) ) % 100;
return $bucket < $pct;
}
private static function log_holdout( $trigger_id, $run, $ctx ) {
global $wpdb;
$t = $wpdb->prefix . 'aieo_push_dispatch';
if ( (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t ) ) !== $t ) {
return;
}
$now = gmdate( 'Y-m-d H:i:s' );
$since = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
$uid = (int) $run['wp_user_id'];
$email = ( $uid > 0 ) ? '' : strtolower( trim( (string) ( $ctx['guest_email'] ?? '' ) ) );
$dup = ( $uid > 0 )
? (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$t}` WHERE channel='holdout' AND trigger_id=%s AND user_id=%d AND created_at >= %s", $trigger_id, $uid, $since ) )
: (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$t}` WHERE channel='holdout' AND trigger_id=%s AND recipient_email=%s AND created_at >= %s", $trigger_id, $email, $since ) );
if ( $dup > 0 ) {
return;
}
$wpdb->insert( $t, array(
'channel' => 'holdout',
'subscription_id' => 0,
'subject_id' => (int) $run['subject_id'],
'recipient_email' => $email,
'user_id' => $uid,
'trigger_id' => (string) $trigger_id,
'template_id' => 0,
'target_product_id' => (int) ( $ctx['product_id'] ?? 0 ),
'state' => 'holdout',
'suppress_reason' => 'holdout',
'queued_at' => $now,
'created_at' => $now,
) );
}
private static function render_email( $subject, $body, $products, $step, $coupon = null, $wf = array() ) {
$site = esc_html( get_bloginfo( 'name' ) );
$cards = '';
foreach ( (array) $products as $pid ) {
if ( ! function_exists( 'wc_get_product' ) ) {
break;
}
$p = wc_get_product( (int) $pid );
if ( ! $p || 'publish' !== $p->get_status() ) {
continue;
}
$img = $p->get_image_id();
$cards .= '
' . esc_html( (string) $coupon['label'] ) . '
' . '' . esc_html__( 'Your code:', 'ai-eshop-optimizer' ) . ' ' . esc_html( strtoupper( (string) $coupon['code'] ) ) . '
' . '' . esc_html__( 'Apply & shop', 'ai-eshop-optimizer' ) . '
'; } elseif ( 'none' !== ( $step['offer'] ?? 'none' ) ) { $val = (int) ( $step['offer_value'] ?? 0 ); $lbl = class_exists( 'AIEO_Coupon_Minter' ) ? AIEO_Coupon_Minter::label( (string) $step['offer'], $val ) : ( ( 'percent' === $step['offer'] ) ? $val . '% off' : ( function_exists( 'wc_price' ) ? html_entity_decode( wp_strip_all_tags( wc_price( $val, array( 'decimals' => 0 ) ) ), ENT_QUOTES, 'UTF-8' ) . ' off' : '€' . $val . ' off' ) ); $offer = '' . esc_html( $lbl ) . '
'; } return '' . '| ' . $site . ' |
| ' . esc_html( $subject ) . ' |
| ' . nl2br( wp_kses_post( $body ) ) . ' |
| ' . $offer . ' |