prefix . 'aieo_email_suppression'; } public static function ensure_schema() { if ( get_option( 'aieo_aw_suppression_schema', '' ) === '1' ) { return; } if ( ! AIEO_Schema_Lock::acquire( 'aieo_aw_suppression_schema' ) ) { return; } global $wpdb; $t = self::suppression_table(); $charset = aieo_get_charset_collate(); $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$t}` ( email_hash char(40) NOT NULL, email varchar(190) NOT NULL DEFAULT '', source varchar(20) NOT NULL DEFAULT '', created_at datetime NOT NULL, PRIMARY KEY (email_hash) ) {$charset};" ); if ( self::table_exists( $t ) ) { update_option( 'aieo_aw_suppression_schema', '1', false ); } } public static function migrate_birthdays() { global $wpdb; $um = $wpdb->usermeta; $lock = $wpdb->prefix . 'aieo_aw_migrate'; if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $lock ) ) ) { return array( 'skipped' => 'locked' ); } try { $md_re = '^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'; $full_re = '^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'; $md_before = self::count_meta( 'aieo_birthday_md' ); $copied_md = (int) $wpdb->query( $wpdb->prepare( "INSERT INTO {$um} (user_id, meta_key, meta_value) SELECT src.user_id, 'aieo_birthday_md', MIN(src.meta_value) FROM {$um} src WHERE src.meta_key = '_automatewoo_birthday_md' AND src.meta_value REGEXP %s AND NOT EXISTS (SELECT 1 FROM {$um} t WHERE t.user_id = src.user_id AND t.meta_key = 'aieo_birthday_md') GROUP BY src.user_id", $md_re ) ); $copied_full = (int) $wpdb->query( $wpdb->prepare( "INSERT INTO {$um} (user_id, meta_key, meta_value) SELECT src.user_id, 'aieo_birthday_full', MIN(src.meta_value) FROM {$um} src WHERE src.meta_key = '_automatewoo_birthday_full' AND src.meta_value REGEXP %s AND NOT EXISTS (SELECT 1 FROM {$um} t WHERE t.user_id = src.user_id AND t.meta_key = 'aieo_birthday_full') GROUP BY src.user_id", $full_re ) ); $uids = array_map( 'intval', (array) $wpdb->get_col( "SELECT DISTINCT user_id FROM {$um} WHERE meta_key IN ('_automatewoo_birthday_md','_automatewoo_birthday_full')" ) ); if ( $uids ) { if ( function_exists( 'wp_cache_delete_multiple' ) ) { foreach ( array_chunk( $uids, 500 ) as $chunk ) { wp_cache_delete_multiple( $chunk, 'user_meta' ); } } else { foreach ( $uids as $uid ) { wp_cache_delete( $uid, 'user_meta' ); } } } $result = array( 'md_before' => $md_before, 'md_after' => self::count_meta( 'aieo_birthday_md' ), 'full_after' => self::count_meta( 'aieo_birthday_full' ), 'copied_md' => $copied_md, 'copied_full' => $copied_full, 'md_source_total' => self::count_meta( '_automatewoo_birthday_md' ), 'md_rejected' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$um} WHERE meta_key = '_automatewoo_birthday_md' AND meta_value <> '' AND meta_value NOT REGEXP %s", $md_re ) ), 'md_shortfall' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT src.user_id) FROM {$um} src WHERE src.meta_key = '_automatewoo_birthday_md' AND src.meta_value REGEXP %s AND NOT EXISTS (SELECT 1 FROM {$um} t WHERE t.user_id = src.user_id AND t.meta_key = 'aieo_birthday_md')", $md_re ) ), 'cache_flushed' => count( $uids ), ); update_option( 'aieo_aw_birthday_migration', $result + array( 'at' => gmdate( 'Y-m-d H:i:s' ) ), false ); return $result; } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } public static function import_coupons() { global $wpdb; $grants = function_exists( 'aieo_coupon_grants_table' ) ? aieo_coupon_grants_table() : ( $wpdb->prefix . 'aieo_coupon_grants' ); if ( ! self::table_exists( $grants ) ) { return array( 'inserted' => 0, 'email_backfilled' => 0, 'redeemed' => 0 ); } $posts = $wpdb->posts; $postmeta = $wpdb->postmeta; $now = gmdate( 'Y-m-d H:i:s' ); $exp_gmt = "DATE_ADD('1970-01-01 00:00:00', INTERVAL CAST(e.meta_value AS UNSIGNED) SECOND)"; $inserted = (int) $wpdb->query( "INSERT IGNORE INTO `{$grants}` (code, coupon_id, source, email, offer_type, offer_value, status, minted_at, sent_at, expires_at) SELECT LOWER(p.post_title), p.ID, 'aw_import', '', IF(dt.meta_value = 'percent', 'percent', 'fixed'), COALESCE(CAST(amt.meta_value AS DECIMAL(12,2)), 0), IF(e.meta_value IS NULL OR e.meta_value = '' OR CAST(e.meta_value AS UNSIGNED) > UNIX_TIMESTAMP(), 'granted', 'expired'), p.post_date_gmt, p.post_date_gmt, IF(e.meta_value IS NULL OR e.meta_value = '', NULL, {$exp_gmt}) FROM `{$posts}` p JOIN `{$postmeta}` a ON a.post_id = p.ID AND a.meta_key = '_is_aw_coupon' LEFT JOIN `{$postmeta}` e ON e.post_id = p.ID AND e.meta_key = 'date_expires' LEFT JOIN `{$postmeta}` dt ON dt.post_id = p.ID AND dt.meta_key = 'discount_type' LEFT JOIN `{$postmeta}` amt ON amt.post_id = p.ID AND amt.meta_key = 'coupon_amount' WHERE p.post_type = 'shop_coupon' AND p.post_status = 'publish' AND p.post_title <> ''" ); $email_backfilled = 0; if ( class_exists( 'WC_Coupon' ) ) { $after = (int) get_option( 'aieo_aw_coupon_backfill_cursor', 0 ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, coupon_id FROM `{$grants}` WHERE source = 'aw_import' AND coupon_id > 0 AND id > %d ORDER BY id ASC LIMIT 2000", $after ), ARRAY_A ); foreach ( (array) $rows as $r ) { $after = (int) $r['id']; $c = new WC_Coupon( (int) $r['coupon_id'] ); $restr = $c->get_email_restrictions(); if ( ! empty( $restr[0] ) && is_email( $restr[0] ) ) { $email = strtolower( sanitize_email( $restr[0] ) ); $user = get_user_by( 'email', $email ); $wpdb->update( $grants, array( 'email' => $email, 'wp_user_id' => $user ? (int) $user->ID : 0 ), array( 'id' => (int) $r['id'] ), array( '%s', '%d' ), array( '%d' ) ); $email_backfilled++; } } update_option( 'aieo_aw_coupon_backfill_cursor', count( $rows ) < 2000 ? 0 : $after, false ); } $redeemed = (int) $wpdb->query( "UPDATE `{$grants}` g JOIN `{$postmeta}` u ON u.post_id = g.coupon_id AND u.meta_key = 'usage_count' SET g.status = 'redeemed' WHERE g.source = 'aw_import' AND g.status IN ('granted','expired') AND u.meta_value + 0 > 0" ); $ocl = $wpdb->prefix . 'wc_order_coupon_lookup'; if ( self::table_exists( $ocl ) ) { $dc_gmt = class_exists( 'AIEO_Order_Store' ) && AIEO_Order_Store::uses_hpos() ? "(SELECT oo.date_created_gmt FROM `{$wpdb->prefix}wc_orders` oo WHERE oo.id = o.order_id)" : "(SELECT pp.post_date_gmt FROM `{$posts}` pp WHERE pp.ID = o.order_id)"; $wpdb->query( "UPDATE `{$grants}` g JOIN ( SELECT coupon_id, order_id, date_created dc FROM ( SELECT coupon_id, order_id, date_created, ROW_NUMBER() OVER (PARTITION BY coupon_id ORDER BY date_created ASC, order_id ASC) rn FROM `{$ocl}` ) ranked WHERE rn = 1 ) o ON o.coupon_id = g.coupon_id SET g.redeemed_order_id = o.order_id, g.redeemed_at = {$dc_gmt} WHERE g.source = 'aw_import' AND g.status = 'redeemed' AND g.redeemed_order_id = 0" ); } $result = array( 'inserted' => $inserted, 'email_backfilled' => $email_backfilled, 'redeemed' => $redeemed ); update_option( 'aieo_aw_coupon_import', $result + array( 'at' => $now ), false ); return $result; } public static function import_optouts() { global $wpdb; self::ensure_schema(); $t = self::suppression_table(); $awc = $wpdb->prefix . 'automatewoo_customers'; if ( ! self::table_exists( $t ) || ! self::table_exists( $awc ) ) { return array( 'imported' => 0 ); } $now = gmdate( 'Y-m-d H:i:s' ); $imported = 0; $users_t = $wpdb->users; $guests = $wpdb->prefix . 'automatewoo_guests'; $sql = "SELECT u.user_email AS email FROM `{$awc}` c JOIN `{$users_t}` u ON u.ID = c.user_id WHERE c.unsubscribed = 1 AND c.user_id > 0 AND u.user_email <> ''"; if ( self::table_exists( $guests ) ) { $sql .= " UNION SELECT g.email AS email FROM `{$awc}` c JOIN `{$guests}` g ON g.id = c.guest_id WHERE c.unsubscribed = 1 AND c.guest_id > 0 AND g.email <> ''"; } $emails = $wpdb->get_col( $sql ); foreach ( (array) $emails as $email ) { $email = strtolower( sanitize_email( (string) $email ) ); if ( '' === $email || ! is_email( $email ) ) { continue; } $n = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$t}` (email_hash, email, source, created_at) VALUES (%s, %s, %s, %s)", sha1( $email ), $email, 'aw_unsub', $now ) ); $imported += (int) $n; } update_option( 'aieo_aw_optout_import', array( 'imported' => $imported, 'at' => $now ), false ); return array( 'imported' => $imported ); } public static function add_suppression( $email, $source = 'optout' ) { global $wpdb; $email = strtolower( sanitize_email( (string) $email ) ); if ( '' === $email || ! is_email( $email ) ) { return false; } self::ensure_schema(); $t = self::suppression_table(); if ( ! self::table_exists( $t ) ) { return false; } $n = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$t}` (email_hash, email, source, created_at) VALUES (%s, %s, %s, %s)", sha1( $email ), $email, substr( sanitize_key( (string) $source ), 0, 20 ), gmdate( 'Y-m-d H:i:s' ) ) ); if ( class_exists( 'AIEO_Contact_Consent' ) ) { AIEO_Contact_Consent::withdraw_consent( $email ); } if ( (int) $n > 0 ) { do_action( 'aieo_customer_opted_out', $email ); return true; } return false; } public static function remove_suppression( $email ) { global $wpdb; $email = strtolower( sanitize_email( (string) $email ) ); if ( '' === $email || ! is_email( $email ) ) { return false; } $t = self::suppression_table(); $removed = self::table_exists( $t ) ? (int) $wpdb->query( $wpdb->prepare( "DELETE FROM `{$t}` WHERE email_hash = %s", sha1( $email ) ) ) : 0; if ( $removed > 0 ) { do_action( 'aieo_customer_opted_in', $email ); return true; } return false; } public static function is_suppressed( $email ) { global $wpdb; $email = strtolower( sanitize_email( (string) $email ) ); if ( '' === $email ) { return false; } $t = self::suppression_table(); static $exists = null; if ( null === $exists || ( false === $exists && '1' === get_option( 'aieo_aw_suppression_schema', '' ) ) ) { $exists = self::table_exists( $t ); } if ( ! $exists ) { return '1' === get_option( 'aieo_aw_suppression_schema', '' ); } return (int) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `{$t}` WHERE email_hash = %s LIMIT 1", sha1( $email ) ) ) === 1; } private static function aw_trigger_map() { return array( 'order_completed' => array( 'order_completed', array( 'status' => 'completed', 'recheck' => 1 ) ), 'order_processing' => array( 'order_completed', array( 'status' => 'processing', 'recheck' => 1 ) ), 'order_payment_received' => array( 'order_completed', array( 'status' => 'processing', 'recheck' => 1 ) ), 'order_status_changes' => array( 'order_completed', array( 'status' => 'completed', 'recheck' => 1 ) ), 'order_cancelled' => array( 'order_completed', array( 'status' => 'cancelled', 'recheck' => 1 ) ), 'order_on_hold' => array( 'order_completed', array( 'status' => 'on-hold', 'recheck' => 1 ) ), 'order_refunded' => array( 'order_completed', array( 'status' => 'refunded', 'recheck' => 1 ) ), 'order_refunded_manual' => array( 'order_completed', array( 'status' => 'refunded', 'recheck' => 1 ) ), 'order_pending' => array( 'order_completed', array( 'status' => 'pending', 'recheck' => 1 ) ), 'order_placed' => array( 'order_completed', array( 'status' => 'processing', 'recheck' => 0 ) ), 'user_purchases_from_taxonomy_term' => array( 'order_completed', array( 'status' => 'completed', 'recheck' => 1 ) ), 'user_purchases_product_variation_with_attribute' => array( 'order_completed', array( 'status' => 'completed', 'recheck' => 1 ) ), 'abandoned_cart' => array( 'abandoned_checkout', array( 'min_age_hours' => 1, 'recheck' => 1 ) ), 'abandoned_cart_customer' => array( 'abandoned_checkout', array( 'min_age_hours' => 1, 'recheck' => 1 ) ), 'guest_abandoned_cart' => array( 'abandoned_checkout', array( 'min_age_hours' => 1, 'recheck' => 1 ) ), 'customer_opted_in' => array( 'opted_in', array() ), 'customer_opted_out' => array( 'opted_out', array() ), 'guest_created' => array( 'guest_created', array() ), 'customer_birthday_trigger' => array( 'birthday', array() ), 'user_absent' => array( 'win_back', array() ), 'user_new_account' => array( 'welcome', array() ), 'order_manual' => array( 'manual', array() ), 'subscription_manual' => array( 'manual', array() ), 'users_total_spend' => array( 'scheduled', array( 'interval' => 'weekly' ) ), 'users_order_count_reaches' => array( 'scheduled', array( 'interval' => 'weekly' ) ), ); } public static function migrate_workflows( $args = array() ) { global $wpdb; if ( ! class_exists( 'AIEO_Workflows' ) ) { return array( 'ok' => false, 'reason' => 'workflows_unavailable' ); } if ( 1 !== (int) $wpdb->get_var( "SELECT GET_LOCK('aieo_aw_migrate_wf', 0)" ) ) { return array( 'ok' => false, 'reason' => 'locked' ); } try { $include_disabled = ! empty( $args['include_disabled'] ); $statuses = $include_disabled ? array( 'publish', 'aw-disabled', 'draft' ) : array( 'publish' ); $ids = get_posts( array( 'post_type' => 'aw_workflow', 'post_status' => $statuses, 'numberposts' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC' ) ); $done = self::migrated_source_ids(); $report = array( 'ok' => true, 'total' => count( (array) $ids ), 'created' => 0, 'skipped_existing' => 0, 'unmapped' => 0, 'items' => array() ); foreach ( (array) $ids as $src ) { $src = (int) $src; $title = get_the_title( $src ); if ( isset( $done[ $src ] ) ) { $report['skipped_existing']++; $report['items'][] = array( 'src' => $src, 'title' => $title, 'status' => 'skipped_existing', 'aieo_id' => $done[ $src ] ); continue; } $built = self::build_aieo_from_aw( $src ); if ( empty( $built['trigger_type'] ) ) { $report['unmapped']++; $report['items'][] = array( 'src' => $src, 'title' => $title, 'status' => 'unmapped', 'notes' => $built['notes'] ); continue; } if ( ! empty( $built['audience_rule']['conditions'] ) && class_exists( 'AIEO_Audience_Rules' ) ) { $aud_id = (int) AIEO_Audience_Rules::create( 'AW: ' . $title, $built['audience_rule'] ); if ( $aud_id > 0 ) { $built['data']['audience_id'] = $aud_id; } } $wf_id = (int) AIEO_Workflows::save( $built['data'] ); $report['created']++; $report['items'][] = array( 'src' => $src, 'title' => $title, 'status' => 'created', 'aieo_id' => $wf_id, 'trigger' => $built['trigger_type'], 'notes' => $built['notes'] ); } return $report; } finally { $wpdb->query( "SELECT RELEASE_LOCK('aieo_aw_migrate_wf')" ); } } private static function migrated_source_ids() { $out = array(); if ( ! class_exists( 'AIEO_Workflows' ) ) { return $out; } foreach ( AIEO_Workflows::list_all() as $wf ) { $s = (int) ( $wf['trigger_config']['aw_source_id'] ?? 0 ); if ( $s > 0 ) { $out[ $s ] = (int) $wf['id']; } } return $out; } private static function build_aieo_from_aw( $src ) { $notes = array(); $aw_trig = (string) get_post_meta( $src, 'trigger_name', true ); $trig_opt = (array) maybe_unserialize( get_post_meta( $src, 'trigger_options', true ) ); $wf_opt = (array) maybe_unserialize( get_post_meta( $src, 'workflow_options', true ) ); $rules = (array) maybe_unserialize( get_post_meta( $src, 'rule_options', true ) ); $actions = (array) maybe_unserialize( get_post_meta( $src, 'actions', true ) ); $title = get_the_title( $src ); $map = self::aw_trigger_map(); if ( ! isset( $map[ $aw_trig ] ) ) { $notes[] = 'trigger "' . $aw_trig . '" has no AIEO equivalent yet'; return array( 'trigger_type' => '', 'data' => array(), 'audience_rule' => array(), 'notes' => $notes ); } list( $trigger_type, $cfg ) = $map[ $aw_trig ]; $cfg = (array) $cfg; $cfg['aw_source_id'] = (int) $src; $is_order = in_array( $trigger_type, array( 'order_completed', 'abandoned_checkout' ), true ); if ( 'birthday' === $trigger_type && isset( $trig_opt['when_to_run'] ) ) { $cfg['days_before'] = abs( (int) $trig_opt['when_to_run'] ); } if ( 'order_status_changes' === $aw_trig ) { $to = $trig_opt['order_status_to'] ?? ''; $all = is_array( $to ) ? array_values( $to ) : ( '' === $to ? array() : array( $to ) ); $to = $all ? preg_replace( '/^wc-/', '', sanitize_key( (string) $all[0] ) ) : ''; $supported = array( 'completed', 'processing', 'on-hold', 'pending', 'cancelled', 'refunded', 'failed', 'checkout-draft' ); if ( '' !== $to && in_array( $to, $supported, true ) ) { $cfg['status'] = $to; } elseif ( '' !== $to ) { $notes[] = 'order_status_changes → destination status "' . $to . '" unsupported; kept "' . $cfg['status'] . '"'; } else { $notes[] = 'order_status_changes had no single destination status; defaulted to "' . $cfg['status'] . '"'; } if ( count( $all ) > 1 ) { $notes[] = 'order_status_changes had ' . count( $all ) . ' destination statuses; mapped only the first ("' . $cfg['status'] . '")'; } if ( ! empty( $trig_opt['order_status_from'] ) ) { $notes[] = 'AW "from" status filter dropped (poll fires on reaching the destination status)'; } } if ( 'order_placed' === $aw_trig ) { $notes[] = 'AW "Order Placed" fired on creation regardless of status; AIEO fires when the order reaches "processing"'; } if ( 'order_refunded_manual' === $aw_trig ) { $notes[] = 'AW manual-refund trigger mapped to the "refunded" status poll (fires on full refund)'; } if ( 'guest_abandoned_cart' === $aw_trig ) { $notes[] = 'Guest abandoned cart mapped to the durable abandoned_checkout trigger (wp_aieo_carts capture — survives WooCommerce’s ~24h draft purge); fires min_age_hours after checkout, recheck-gated on non-conversion'; } foreach ( (array) $rules as $rc_group ) { if ( ! is_array( $rc_group ) ) { continue; } foreach ( $rc_group as $rc_cond ) { if ( ! is_array( $rc_cond ) || 'customer_run_count' !== (string) ( $rc_cond['name'] ?? '' ) ) { continue; } $rc_cmp = (string) ( $rc_cond['compare'] ?? '' ); if ( in_array( $rc_cmp, array( 'less_than', 'less_than_or_equal' ), true ) && (float) ( $rc_cond['value'] ?? 0 ) <= 1 ) { $cfg['once_per_customer'] = true; $notes[] = 'AW run-count < 1 → send once per customer (trigger_config.once_per_customer)'; } } } $mapped = self::map_rules( $rules, $is_order, $notes ); if ( $is_order && ! empty( $mapped['order']['conditions'] ) ) { $cfg['order_rules'] = $mapped['order']; } elseif ( ! empty( $mapped['order']['conditions'] ) ) { $notes[] = 'dropped ' . count( $mapped['order']['conditions'] ) . ' order-scoped rule(s) (not applicable to ' . $trigger_type . ')'; } $audience = $mapped['audience']; if ( 'user_absent' === $aw_trig ) { $min = (int) ( $trig_opt['days_since_last_purchase'] ?? 0 ); $max = (int) ( $trig_opt['days_since_last_purchase_max'] ?? 0 ); if ( $min > 0 ) { $audience['conditions'][] = array( 'field' => 'recency_days', 'op' => '>=', 'value' => $min ); } if ( $max > 0 ) { $audience['conditions'][] = array( 'field' => 'recency_days', 'op' => '<=', 'value' => $max ); } } if ( 'users_total_spend' === $aw_trig && isset( $trig_opt['total_spend'] ) ) { $audience['conditions'][] = array( 'field' => 'ltv_total', 'op' => '>=', 'value' => (float) $trig_opt['total_spend'] ); } if ( 'users_order_count_reaches' === $aw_trig ) { $n = (int) ( $trig_opt['order_count'] ?? ( $trig_opt['count'] ?? 0 ) ); if ( $n > 0 ) { $audience['conditions'][] = array( 'field' => 'orders_count', 'op' => '>=', 'value' => $n ); } } if ( empty( $audience['op'] ) ) { $audience = array( 'op' => 'and', 'conditions' => isset( $audience['conditions'] ) ? $audience['conditions'] : array() ); } if ( 'user_purchases_from_taxonomy_term' === $aw_trig ) { $term = (string) ( $trig_opt['term'] ?? '' ); if ( false !== strpos( $term, '|' ) ) { list( $tid, $tax ) = explode( '|', $term, 2 ); $tid = (int) $tid; $tax = sanitize_key( $tax ); if ( $tid > 0 && '' !== $tax ) { $cfg['order_rules'] = self::append_order_rule( isset( $cfg['order_rules'] ) ? $cfg['order_rules'] : array(), array( 'field' => 'order_taxonomy', 'op' => 'includes', 'value' => array( 'tax' => $tax, 'ids' => array( (string) $tid ) ) ) ); } } } if ( 'user_purchases_product_variation_with_attribute' === $aw_trig ) { $attr = sanitize_key( (string) ( $trig_opt['attribute'] ?? '' ) ); $terms = (array) ( $trig_opt['term'] ?? array() ); $or = array(); foreach ( $terms as $t ) { if ( ! is_string( $t ) || false === strpos( $t, '|' ) ) { continue; } list( $tid, $tax ) = explode( '|', $t, 2 ); $term_obj = function_exists( 'get_term' ) ? get_term( (int) $tid, sanitize_key( $tax ) ) : null; $slug = ( $term_obj && ! is_wp_error( $term_obj ) ) ? $term_obj->slug : ''; if ( '' !== $slug ) { $or[] = array( 'field' => 'order_variation_attribute', 'op' => 'includes', 'value' => array( 'attribute' => ( '' !== $attr ? $attr : sanitize_key( $tax ) ), 'value' => $slug ) ); } } if ( $or ) { $node = ( 1 === count( $or ) ) ? $or[0] : array( 'op' => 'or', 'conditions' => $or ); $cfg['order_rules'] = self::append_order_rule( isset( $cfg['order_rules'] ) ? $cfg['order_rules'] : array(), $node ); } } $steps = array(); $wait = self::map_timing( $wf_opt, $aw_trig, $notes ); if ( $wait ) { $steps[] = $wait; } $steps = array_merge( $steps, self::map_actions( $actions, $title, $notes ) ); $data = array( 'name' => 'AW: ' . $title, 'status' => 'draft', 'trigger_type' => $trigger_type, 'trigger_config' => $cfg, 'audience_id' => 0, 'steps' => $steps, ); return array( 'trigger_type' => $trigger_type, 'data' => $data, 'audience_rule' => $audience, 'notes' => array_values( array_unique( $notes ) ) ); } private static function map_rules( $rule_options, $is_order, &$notes ) { $order_groups = array(); $aud_groups = array(); foreach ( (array) $rule_options as $group ) { if ( ! is_array( $group ) ) { continue; } $o = array(); $a = array(); foreach ( $group as $cond ) { if ( ! is_array( $cond ) ) { continue; } $leaf = self::map_rule_leaf( $cond, $is_order ); if ( null === $leaf ) { if ( 'customer_run_count' !== (string) ( $cond['name'] ?? '' ) ) { $notes[] = 'unmapped rule "' . ( $cond['name'] ?? '?' ) . '"'; } continue; } if ( 'order' === $leaf['scope'] ) { $o[] = $leaf['leaf']; } else { $a[] = $leaf['leaf']; } } if ( $o ) { $order_groups[] = array( 'op' => 'and', 'conditions' => $o ); } if ( $a ) { $aud_groups[] = array( 'op' => 'and', 'conditions' => $a ); } } return array( 'order' => self::wrap_groups( $order_groups ), 'audience' => self::wrap_groups( $aud_groups ) ); } private static function wrap_groups( $groups ) { if ( empty( $groups ) ) { return array( 'op' => 'and', 'conditions' => array() ); } if ( 1 === count( $groups ) ) { return $groups[0]; } return array( 'op' => 'or', 'conditions' => $groups ); } private static function append_order_rule( $tree, $node ) { if ( empty( $tree ) || empty( $tree['conditions'] ) ) { return array( 'op' => 'and', 'conditions' => array( $node ) ); } $tree['conditions'][] = $node; return $tree; } private static function map_rule_leaf( $cond, $is_order ) { $name = (string) ( $cond['name'] ?? '' ); $cmp = (string) ( $cond['compare'] ?? '' ); $val = $cond['value'] ?? ''; $num = array( 'greater_than' => '>', 'less_than' => '<', 'is' => '=', 'is_equal_to' => '=', 'greater_than_or_equal' => '>=', 'less_than_or_equal' => '<=' ); $not = ( strpos( $cmp, 'not' ) !== false || 'excludes' === $cmp || 'matches_none' === $cmp ); switch ( $name ) { case 'order_items': return $is_order ? array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_items', 'op' => ( $not ? 'not_includes' : 'includes' ), 'value' => self::idlist( $val ) ) ) : array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_product', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'order_item_categories': return $is_order ? array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_categories', 'op' => ( $not ? 'not_includes' : 'includes' ), 'value' => self::idlist( $val ) ) ) : array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_category', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'order_item_count': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_qty_count', 'op' => ( $num[ $cmp ] ?? '=' ), 'value' => (float) $val ) ); case 'order_line_count': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_item_count', 'op' => ( $num[ $cmp ] ?? '=' ), 'value' => (float) $val ) ); case 'order_total': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_total', 'op' => ( $num[ $cmp ] ?? '>' ), 'value' => (float) $val ) ); case 'order_item_tags': return $is_order ? array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_tags', 'op' => ( $not ? 'not_includes' : 'includes' ), 'value' => self::idlist( $val ) ) ) : array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_tag', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'order_payment_gateway': case 'order_payment_method': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_payment_gateway', 'op' => ( 'is_not' === $cmp ? 'is_not' : ( 'is' === $cmp ? 'is' : 'in' ) ), 'value' => is_array( $val ) ? array_values( $val ) : $val ) ); case 'order_billing_country': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_billing_country', 'op' => ( 'is_not' === $cmp ? 'is_not' : ( 'is' === $cmp ? 'is' : 'in' ) ), 'value' => is_array( $val ) ? array_values( $val ) : $val ) ); case 'order_shipping_country': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_shipping_country', 'op' => ( 'is_not' === $cmp ? 'is_not' : ( 'is' === $cmp ? 'is' : 'in' ) ), 'value' => is_array( $val ) ? array_values( $val ) : $val ) ); case 'order_created_via': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_created_via', 'op' => ( 'is_not' === $cmp ? 'is_not' : ( 'is' === $cmp ? 'is' : 'in' ) ), 'value' => is_array( $val ) ? array_values( $val ) : $val ) ); case 'order_coupons': case 'order_coupons_text_match': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_coupons', 'op' => ( $not ? 'not_includes' : 'includes' ), 'value' => is_array( $val ) ? array_values( $val ) : array( $val ) ) ); case 'order_shipping_method': case 'order_shipping_method_string': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_shipping_method', 'op' => ( $not ? 'not_includes' : 'includes' ), 'value' => is_array( $val ) ? array_values( $val ) : array( $val ) ) ); case 'customer_last_order_date': $date = is_array( $val ) ? (string) ( $val['date'] ?? '' ) : ''; if ( '' === $date ) { return null; } $days = (int) floor( ( time() - strtotime( $date . ' 00:00:00' ) ) / DAY_IN_SECONDS ); if ( $days < 0 ) { $days = 0; } return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'recency_days', 'op' => ( 'is_after' === $cmp ? '<=' : '>=' ), 'value' => $days ) ); case 'customer_role': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'customer_role', 'op' => ( 'is_not' === $cmp ? 'is_not' : 'is' ), 'value' => array_values( (array) $val ) ) ); case 'customer_order_count': return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'orders_count', 'op' => ( $num[ $cmp ] ?? '>' ), 'value' => (float) $val ) ); case 'customer_total_spent': return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ltv_total', 'op' => ( $num[ $cmp ] ?? '>' ), 'value' => (float) $val ) ); case 'customer_purchased_categories': return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_category', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'customer_purchased_products': return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_product', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'customer_purchased_tags': return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_tag', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'customer_purchased_brands': return array( 'scope' => 'audience', 'leaf' => array( 'field' => 'ever_purchased_brand', 'op' => ( $not ? 'not_in' : 'in' ), 'value' => self::idlist( $val ) ) ); case 'order_is_customers_first': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_is_first', 'op' => ( 'is_not' === $cmp ? 'is_not' : 'is' ), 'value' => ( 'yes' === strtolower( (string) $val ) ) ) ); case 'cart_total': return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_total', 'op' => ( $num[ $cmp ] ?? '>' ), 'value' => (float) $val ) ); case 'customer_email': $email_op = array( 'not_contains' => 'not_contains', 'contains' => 'contains', 'is' => 'is', 'is_not' => 'is_not' ); return array( 'scope' => 'order', 'leaf' => array( 'field' => 'order_billing_email', 'op' => ( $email_op[ $cmp ] ?? 'not_contains' ), 'value' => (string) $val ) ); } return null; } private static function idlist( $v ) { $v = is_array( $v ) ? $v : ( '' === $v ? array() : array( $v ) ); return array_values( array_map( 'strval', array_filter( array_map( 'intval', $v ) ) ) ); } private static function map_timing( $wf_opt, $aw_trig, &$notes ) { if ( 'customer_birthday_trigger' === $aw_trig ) { return null; } $when = (string) ( $wf_opt['when_to_run'] ?? 'immediately' ); if ( 'delayed' === $when ) { $v = (float) ( $wf_opt['run_delay_value'] ?? 0 ); $u = (string) ( $wf_opt['run_delay_unit'] ?? 'd' ); if ( $v <= 0 ) { return null; } if ( 'm' === $u ) { return array( 'type' => 'wait', 'value' => round( $v / 60, 2 ), 'unit' => 'hours' ); } if ( 'h' === $u ) { return array( 'type' => 'wait', 'value' => $v, 'unit' => 'hours' ); } if ( 'w' === $u ) { return array( 'type' => 'wait', 'value' => $v * 7, 'unit' => 'days' ); } return array( 'type' => 'wait', 'value' => $v, 'unit' => 'days' ); } if ( 'fixed' === $when ) { $notes[] = 'AW "fixed date" timing (' . ( $wf_opt['fixed_date'] ?? '' ) . ') — set a schedule / run manually'; } return null; } private static function map_actions( $actions, $title, &$notes ) { $steps = array(); $send_emitted = false; foreach ( (array) $actions as $a ) { if ( ! is_array( $a ) ) { continue; } $name = (string) ( $a['action_name'] ?? '' ); $step = self::map_single_action( $name, $a, $notes ); if ( null !== $step ) { $steps[] = $step; continue; } if ( '' === $name || self::is_email_action( $name ) ) { $to = trim( (string) ( $a['to'] ?? ( $a['recipients'] ?? '' ) ) ); if ( '' !== $to && false === strpos( $to, '{{' ) && false === strpos( $to, '[' ) ) { $notes[] = 'AW email had an explicit recipient ("' . $to . '") — it migrates as a CUSTOMER send; re-target or delete it'; } if ( $send_emitted ) { $notes[] = 'multiple AW email actions collapsed into ONE send step — review the copy (only the first subject was kept)'; } else { $steps[] = self::map_send_action( $actions, $title, $notes ); $send_emitted = true; } } elseif ( ! self::is_known_action( $name ) ) { $notes[] = 'action "' . $name . '" has no AIEO equivalent — review this step'; } } if ( empty( $steps ) ) { $steps[] = self::map_send_action( $actions, $title, $notes ); } return $steps; } private static function is_known_action( $name ) { return in_array( $name, array( 'change_order_status', 'order_add_note', 'customer_change_role', 'update_order_meta', 'customer_update_meta', 'custom_function' ), true ); } private static function is_email_action( $name ) { return in_array( $name, array( 'send_email', 'send_email_plain', 'send_email_raw', 'send_sms', 'send_sms_twilio', 'resend_order_email' ), true ) || ( '' !== $name && ( false !== strpos( $name, 'email' ) || false !== strpos( $name, 'sms' ) ) ); } private static function map_single_action( $name, $a, &$notes ) { switch ( $name ) { case 'change_order_status': $to = preg_replace( '/^wc-/', '', sanitize_key( (string) ( $a['order_status'] ?? '' ) ) ); $allowed = array( 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'pending', 'failed' ); if ( '' === $to || ! in_array( $to, $allowed, true ) ) { $notes[] = 'change_order_status had no / an unsupported status ("' . $to . '") — step DROPPED; add it manually to avoid an unintended status change'; return null; } return array( 'type' => 'change_order_status', 'order_status' => $to, 'note' => '' ); case 'order_add_note': return array( 'type' => 'order_add_note', 'note' => (string) ( $a['note'] ?? '' ), 'customer_note' => ( 'customer' === (string) ( $a['note_type'] ?? '' ) ) ? 1 : 0 ); case 'customer_change_role': $role = sanitize_key( (string) ( $a['user_type'] ?? '' ) ); if ( ! class_exists( 'AIEO_Safe_Roles' ) || ! AIEO_Safe_Roles::may_hand_out( $role ) ) { $notes[] = 'customer_change_role named "' . $role . '", which is not a customer role a workflow may give (it grants more than reading, it is given by hand only, or the site has no such role) — step DROPPED'; return null; } return array( 'type' => 'customer_change_role', 'mode' => 'replace', 'role' => $role ); case 'update_order_meta': return array( 'type' => 'order_update_meta', 'meta_key' => (string) ( $a['meta_key'] ?? '' ), 'meta_value' => (string) ( $a['meta_value'] ?? '' ) ); case 'customer_update_meta': return array( 'type' => 'customer_update_meta', 'meta_key' => (string) ( $a['meta_key'] ?? '' ), 'meta_value' => (string) ( $a['meta_value'] ?? '' ) ); case 'custom_function': return array( 'type' => 'custom_function', 'function_name' => (string) ( $a['function_name'] ?? '' ) ); } return null; } private static function map_send_action( $actions, $title, &$notes ) { $subject = ''; $body = ''; $has_coupon = false; $is_crosssell = false; foreach ( (array) $actions as $a ) { if ( ! is_array( $a ) ) { continue; } if ( '' === $subject && ! empty( $a['subject'] ) ) { $subject = html_entity_decode( (string) $a['subject'], ENT_QUOTES, 'UTF-8' ); } if ( '' === $body && ( ! empty( $a['email_content'] ) || ! empty( $a['email_html'] ) ) ) { $raw = (string) ( ! empty( $a['email_content'] ) ? $a['email_content'] : $a['email_html'] ); $body = html_entity_decode( $raw, ENT_QUOTES, 'UTF-8' ); } if ( isset( $a['action_name'] ) && strpos( (string) $a['action_name'], 'coupon' ) !== false ) { $has_coupon = true; } foreach ( (array) $a as $v ) { if ( is_string( $v ) && strpos( $v, 'generate_coupon' ) !== false ) { $has_coupon = true; } if ( is_string( $v ) && strpos( $v, 'related_products' ) !== false ) { $is_crosssell = true; } } } $offer = 'none'; $offer_val = 0; if ( $has_coupon ) { $hay = $title . ' ' . $subject; if ( preg_match( '/(\d+)\s*%/u', $hay, $m ) ) { $offer = 'percent'; $offer_val = (int) $m[1]; } elseif ( preg_match( '/(\d+)\s*(?:€|ευρ|euro)/ui', $hay, $m ) ) { $offer = 'fixed'; $offer_val = (int) $m[1]; } else { $notes[] = 'AW email generates a coupon — set the Offer amount on the send step'; } } if ( '' !== $body && class_exists( 'AIEO_Merge_Tags' ) ) { if ( preg_match_all( '/\{\{\s*([a-z0-9_.]+)/u', $subject . ' ' . $body, $mm ) ) { $catalog = array_flip( AIEO_Merge_Tags::CATALOG ); $unmapped = array_values( array_unique( array_filter( $mm[1], function ( $t ) use ( $catalog ) { return false !== strpos( $t, '.' ) && ! isset( $catalog[ $t ] ); } ) ) ); if ( $unmapped ) { $notes[] = 'AW copy uses merge tags not yet supported (render blank): {{' . implode( '}}, {{', $unmapped ) . '}} — review'; } if ( false !== strpos( $body, 'generate_coupon' ) ) { $notes[] = 'Body inlines the coupon code — the send step also renders a coupon box; remove one during review'; } } } return array( 'type' => 'send', 'channel' => 'email', 'subject' => '' !== $subject ? $subject : $title, 'body' => '' !== $body ? $body : __( 'Migrated from AutomateWoo — review & paste the email copy here.', 'ai-eshop-optimizer' ), 'product_source' => '' !== $body ? 'none' : ( $is_crosssell ? 'recommendations' : 'none' ), 'offer' => $offer, 'offer_value' => $offer_val, 'offer_ttl_days' => 14, ); } private static function count_meta( $key ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value <> ''", $key ) ); } private static function table_exists( $t ) { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t ) ) === $t; } } if ( defined( 'WP_CLI' ) && WP_CLI && ! class_exists( 'AIEO_AW_Decommission_CLI' ) ) { class AIEO_AW_Decommission_CLI { public function migrate_birthdays() { $r = AIEO_AW_Decommission::migrate_birthdays(); if ( isset( $r['skipped'] ) ) { \WP_CLI::warning( 'Skipped (another migration is running): ' . $r['skipped'] . '.' ); return; } \WP_CLI::success( 'Birthdays: copied ' . $r['copied_md'] . ' md + ' . $r['copied_full'] . ' full; total md now ' . $r['md_after'] . '; rejected ' . $r['md_rejected'] . '; shortfall ' . $r['md_shortfall'] . '.' ); } public function import_coupons() { $r = AIEO_AW_Decommission::import_coupons(); \WP_CLI::success( 'Coupons: inserted ' . $r['inserted'] . '; email-backfilled ' . $r['email_backfilled'] . '; marked redeemed ' . $r['redeemed'] . '.' ); } public function import_optouts() { $r = AIEO_AW_Decommission::import_optouts(); \WP_CLI::success( 'Opt-outs imported: ' . $r['imported'] . '.' ); } public function migrate_workflows( $pos, $assoc ) { $r = AIEO_AW_Decommission::migrate_workflows( array( 'include_disabled' => ! empty( $assoc['include-disabled'] ) ) ); if ( empty( $r['ok'] ) ) { \WP_CLI::warning( 'Not run: ' . ( $r['reason'] ?? 'unknown' ) . '.' ); return; } foreach ( (array) $r['items'] as $it ) { $line = ' [' . $it['status'] . '] #' . $it['src'] . ' ' . $it['title']; if ( ! empty( $it['aieo_id'] ) ) { $line .= ' → AIEO #' . $it['aieo_id']; } if ( ! empty( $it['notes'] ) ) { $line .= ' {' . implode( '; ', (array) $it['notes'] ) . '}'; } \WP_CLI::log( $line ); } \WP_CLI::success( 'Workflows: created ' . $r['created'] . ', skipped-existing ' . $r['skipped_existing'] . ', unmapped ' . $r['unmapped'] . ' (of ' . $r['total'] . ' active).' ); } } }