0 ) { self::stamp_identity( $subject_id, $user_id ); } } public static function resolve_from_email( $email, $visitor_key = '' ) { $email = trim( (string) $email ); if ( '' === $email || ! is_email( $email ) || ! self::tables_exist() ) { return 0; } $visitor_key = ( '' !== (string) $visitor_key ) ? (string) $visitor_key : self::current_visitor_key(); if ( '' === $visitor_key ) { return 0; } $subject_id = self::get_or_create_subject( $visitor_key ); if ( $subject_id <= 0 ) { return 0; } return self::stamp_identity( $subject_id, 0, $email ); } public static function maybe_schedule_backfill() { static $checked = false; if ( $checked ) { return; } $checked = true; if ( get_transient( 'aieo_subjects_backfill_schedule_lock' ) ) { return; } set_transient( 'aieo_subjects_backfill_schedule_lock', 1, 5 * MINUTE_IN_SECONDS ); if ( ! self::tables_exist() ) { return; } if ( function_exists( 'as_next_scheduled_action' ) && function_exists( 'as_schedule_recurring_action' ) ) { if ( false !== as_next_scheduled_action( self::HOOK_BACKFILL, null, 'aieo' ) ) { return; } as_schedule_recurring_action( time() + 300, self::BACKFILL_PERIOD, self::HOOK_BACKFILL, array(), 'aieo' ); return; } if ( ! wp_next_scheduled( self::HOOK_BACKFILL ) ) { wp_schedule_event( time() + 300, 'daily', self::HOOK_BACKFILL ); } } public static function maybe_schedule_tick() { 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_subjects_schedule_check_lock' ) ) { return; } set_transient( 'aieo_subjects_schedule_check_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' ) ) { return; } as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, self::HOOK_TICK, array(), 'aieo' ); return; } if ( ! 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; } $existing = self::find_existing_tick_action(); return ( $existing > 0 ) ? $existing : $pre; } private static function find_existing_tick_action() { global $wpdb; $lock_key = 'aieo_subjects_tick_sched'; $got = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock_key ) ); if ( 1 !== $got ) { 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; } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_key ) ); } } public static function tick() { if ( ! self::tables_exist() ) { return; } global $wpdb; $lock = 'aieo_subjects_resolve'; if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ) ) { return; } try { $processed = self::resolve_pending_batch( self::BATCH_SIZE ); if ( $processed >= self::BATCH_SIZE && 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 resolve_pending_batch( $limit ) { global $wpdb; $si = self::session_identity_table(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, session_id, visitor_key, user_id FROM `{$si}` WHERE resolved_via = 'pending' ORDER BY id ASC LIMIT %d", (int) $limit ), ARRAY_A ); if ( empty( $rows ) ) { return 0; } $now = self::now(); foreach ( $rows as $r ) { $id = (int) $r['id']; $visitor_key = (string) $r['visitor_key']; $user_id = (int) $r['user_id']; if ( '' === $visitor_key ) { $wpdb->update( $si, array( 'resolved_via' => 'nokey', 'updated' => $now ), array( 'id' => $id ) ); continue; } $subject_id = self::get_or_create_subject( $visitor_key ); $via = 'cookie'; if ( $subject_id > 0 && $user_id > 0 ) { $subject_id = self::stamp_identity( $subject_id, $user_id ); $via = 'user'; } $wpdb->update( $si, array( 'subject_id' => (int) $subject_id, 'resolved_via' => $via, 'updated' => $now ), array( 'id' => $id ) ); } return count( $rows ); } public static function get_or_create_subject( $visitor_key ) { global $wpdb; $visitor_key = (string) $visitor_key; if ( '' === $visitor_key ) { return 0; } $t = self::subjects_table(); $now = self::now(); $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$t}` (visitor_key, first_seen, last_seen, created, updated) VALUES (%s, %s, %s, %s, %s)", $visitor_key, $now, $now, $now, $now ) ); $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT subject_id FROM `{$t}` WHERE visitor_key = %s", $visitor_key ) ); return $id > 0 ? self::canonical( $id ) : 0; } public static function stamp_identity( $subject_id, $wp_user_id = 0, $email = '', $eponymous_id = 0 ) { global $wpdb; $subject_id = self::canonical( (int) $subject_id ); $wp_user_id = (int) $wp_user_id; $eponymous_id = (int) $eponymous_id; $email = (string) $email; if ( $subject_id <= 0 ) { return 0; } if ( $wp_user_id > 0 && '' === $email ) { $u = get_userdata( $wp_user_id ); if ( $u && ! empty( $u->user_email ) ) { $email = (string) $u->user_email; } } if ( $eponymous_id <= 0 ) { list( $epo, $ci_email ) = self::resolve_from_ci( $wp_user_id, $email ); $eponymous_id = (int) $epo; if ( '' === $email && '' !== $ci_email ) { $email = $ci_email; } } $email_hash = ( '' !== $email ) ? md5( strtolower( trim( $email ) ) ) : ''; $survivor = self::merge_colliding( $subject_id, $wp_user_id, $eponymous_id, $email_hash ); $sets = array(); $vals = array(); if ( $wp_user_id > 0 ) { $sets[] = 'wp_user_id = %d'; $vals[] = $wp_user_id; } if ( $eponymous_id > 0 ) { $sets[] = 'eponymous_id = %d'; $vals[] = $eponymous_id; } if ( '' !== $email_hash ) { $sets[] = 'email_hash = %s'; $vals[] = $email_hash; } if ( empty( $sets ) ) { return $survivor; } $sets[] = 'last_seen = %s'; $vals[] = self::now(); $sets[] = 'updated = %s'; $vals[] = self::now(); $vals[] = $survivor; $wpdb->query( $wpdb->prepare( "UPDATE `" . self::subjects_table() . "` SET " . implode( ', ', $sets ) . " WHERE subject_id = %d", $vals ) ); return $survivor; } private static function merge_colliding( $subject_id, $wp_user_id, $eponymous_id, $email_hash = '' ) { global $wpdb; $t = self::subjects_table(); $conds = array(); $vals = array(); if ( $wp_user_id > 0 ) { $conds[] = 'wp_user_id = %d'; $vals[] = $wp_user_id; } if ( $eponymous_id > 0 ) { $conds[] = 'eponymous_id = %d'; $vals[] = $eponymous_id; } if ( '' !== (string) $email_hash ) { $conds[] = 'email_hash = %s'; $vals[] = (string) $email_hash; } if ( empty( $conds ) ) { return $subject_id; } $ids = $wpdb->get_col( $wpdb->prepare( "SELECT subject_id FROM `{$t}` WHERE ( " . implode( ' OR ', $conds ) . " ) AND merged_into = 0", $vals ) ); $ids = array_map( 'intval', (array) $ids ); $ids[] = (int) $subject_id; $ids = array_values( array_unique( array_filter( $ids ) ) ); if ( count( $ids ) < 2 ) { return (int) $subject_id; } $survivor = min( $ids ); foreach ( $ids as $id ) { if ( $id !== $survivor ) { self::merge_subjects( $survivor, $id ); } } return $survivor; } public static function merge_subjects( $survivor_id, $loser_id ) { global $wpdb; $survivor_id = (int) $survivor_id; $loser_id = (int) $loser_id; if ( $survivor_id <= 0 || $loser_id <= 0 || $survivor_id === $loser_id ) { return; } $now = self::now(); $wpdb->query( $wpdb->prepare( "UPDATE `" . self::session_identity_table() . "` SET subject_id = %d, updated = %s WHERE subject_id = %d", $survivor_id, $now, $loser_id ) ); $wpdb->query( $wpdb->prepare( "UPDATE `" . self::subjects_table() . "` SET merged_into = %d, updated = %s WHERE subject_id = %d", $survivor_id, $now, $loser_id ) ); require_once __DIR__ . '/class-aieo-visitor-lifetime.php'; AIEO_Visitor_Lifetime::merge( $survivor_id, $loser_id ); } private static function resolve_from_ci( $wp_user_id, $email ) { global $wpdb; $ci = self::ci_table(); if ( ! self::table_exists( $ci ) ) { return array( 0, '' ); } $epo = 0; $found = ''; if ( $wp_user_id > 0 ) { $row = $wpdb->get_row( $wpdb->prepare( "SELECT eponymous_id, email FROM `{$ci}` WHERE wp_user_id = %d LIMIT 1", $wp_user_id ), ARRAY_A ); if ( $row ) { $epo = (int) $row['eponymous_id']; $found = (string) $row['email']; } } if ( $epo <= 0 && '' !== $email ) { $epo = (int) $wpdb->get_var( $wpdb->prepare( "SELECT eponymous_id FROM `{$ci}` WHERE email = %s LIMIT 1", $email ) ); } return array( $epo, '' !== $found ? $found : $email ); } public static function on_login( $user_login, $user ) { if ( ! is_object( $user ) || empty( $user->ID ) || ! self::tables_exist() ) { return; } $visitor_key = self::current_visitor_key(); if ( '' === $visitor_key ) { return; } $subject_id = self::get_or_create_subject( $visitor_key ); if ( $subject_id > 0 ) { self::stamp_identity( $subject_id, (int) $user->ID ); } } public static function subject_for_session( $session_id ) { global $wpdb; $session_id = (string) $session_id; if ( '' === $session_id || ! self::tables_exist() ) { return 0; } $si = self::session_identity_table(); $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT subject_id FROM `{$si}` WHERE session_id = %s", $session_id ) ); return $id > 0 ? self::canonical( $id ) : 0; } public static function subject_for_visitor( $visitor_key ) { global $wpdb; $visitor_key = (string) $visitor_key; if ( '' === $visitor_key || ! self::tables_exist() ) { return 0; } $t = self::subjects_table(); $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT subject_id FROM `{$t}` WHERE visitor_key = %s", $visitor_key ) ); return $id > 0 ? self::canonical( $id ) : 0; } public static function subject_for_user( $wp_user_id ) { global $wpdb; $wp_user_id = (int) $wp_user_id; if ( $wp_user_id <= 0 || ! self::tables_exist() ) { return 0; } $t = self::subjects_table(); $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT subject_id FROM `{$t}` WHERE wp_user_id = %d AND merged_into = 0 ORDER BY subject_id ASC LIMIT 1", $wp_user_id ) ); return $id > 0 ? self::canonical( $id ) : 0; } private static function canonical( $id ) { global $wpdb; $id = (int) $id; $t = self::subjects_table(); $seen = array(); while ( $id > 0 && empty( $seen[ $id ] ) ) { $seen[ $id ] = true; $m = (int) $wpdb->get_var( $wpdb->prepare( "SELECT merged_into FROM `{$t}` WHERE subject_id = %d", $id ) ); if ( $m > 0 ) { $id = $m; } else { break; } } return $id; } private static function current_visitor_key() { if ( class_exists( 'AIEO_Attribution' ) && method_exists( 'AIEO_Attribution', 'read_visitor_key' ) ) { return (string) AIEO_Attribution::read_visitor_key(); } if ( ! empty( $_COOKIE['aieo_attr_sid'] ) ) { return sanitize_text_field( wp_unslash( $_COOKIE['aieo_attr_sid'] ) ); } return ''; } private static function now() { return gmdate( 'Y-m-d H:i:s' ); } public static function backfill_from_orders( $limit = self::BACKFILL_BATCH ) { global $wpdb; $out = array( 'scanned' => 0, 'resolved' => 0, 'remaining' => 0, 'skipped' => 0 ); $limit = max( 1, min( 5000, (int) $limit ) ); if ( ! self::tables_exist() || ! function_exists( 'wc_get_order' ) ) { return $out; } $sources = array_values( array_filter( array( $wpdb->prefix . 'aieo_sessions', $wpdb->prefix . 'aieo_sessions_archive' ), array( __CLASS__, 'table_exists' ) ) ); if ( empty( $sources ) ) { return $out; } $subjects = self::subjects_table(); $parts = array(); foreach ( $sources as $tbl ) { $parts[] = "SELECT DISTINCT s.session_id, s.order_id FROM `{$tbl}` s LEFT JOIN `{$subjects}` sub ON sub.visitor_key = s.session_id WHERE s.order_id > 0 AND s.session_id <> '' AND ( sub.subject_id IS NULL OR ( sub.wp_user_id = 0 AND sub.eponymous_id = 0 AND sub.email_hash = '' AND sub.merged_into = 0 ) )"; } $sql = implode( ' UNION ', $parts ) . ' LIMIT ' . (int) ( $limit + 1 ); $rows = $wpdb->get_results( $sql, ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); if ( count( $rows ) > $limit ) { $rows = array_slice( $rows, 0, $limit ); $out['remaining'] = 1; } foreach ( $rows as $r ) { $out['scanned']++; $visitor_key = (string) $r['session_id']; $order = wc_get_order( (int) $r['order_id'] ); if ( ! $order ) { $out['skipped']++; continue; } $user_id = (int) $order->get_customer_id(); $email = (string) $order->get_billing_email(); if ( $user_id <= 0 && '' === $email ) { $out['skipped']++; continue; } $subject_id = self::get_or_create_subject( $visitor_key ); if ( $subject_id <= 0 ) { $out['skipped']++; continue; } self::stamp_identity( $subject_id, $user_id, $email ); $out['resolved']++; } return $out; } public static function backfill_pending_count() { global $wpdb; if ( ! self::tables_exist() ) { return 0; } $sources = array_values( array_filter( array( $wpdb->prefix . 'aieo_sessions', $wpdb->prefix . 'aieo_sessions_archive' ), array( __CLASS__, 'table_exists' ) ) ); if ( empty( $sources ) ) { return 0; } $subjects = self::subjects_table(); $parts = array(); foreach ( $sources as $tbl ) { $parts[] = "SELECT DISTINCT s.session_id FROM `{$tbl}` s LEFT JOIN `{$subjects}` sub ON sub.visitor_key = s.session_id WHERE s.order_id > 0 AND s.session_id <> '' AND ( sub.subject_id IS NULL OR ( sub.wp_user_id = 0 AND sub.eponymous_id = 0 AND sub.email_hash = '' AND sub.merged_into = 0 ) )"; } return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ( ' . implode( ' UNION ', $parts ) . ' ) q' ); } public static function backfill_tick() { $res = self::backfill_from_orders( self::BACKFILL_BATCH ); $res['reresolved'] = self::reresolve_known_emails(); if ( ! empty( $res['remaining'] ) && function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::HOOK_BACKFILL, array(), 'aieo' ); } return $res; } public static function reresolve_known_emails( $limit = 500 ) { global $wpdb; if ( ! self::tables_exist() || ! self::table_exists( self::ci_table() ) ) { return 0; } $limit = max( 1, min( 5000, (int) $limit ) ); $subs = self::subjects_table(); $ci = self::ci_table(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT sub.subject_id, ci.eponymous_id, ci.wp_user_id FROM `{$subs}` sub JOIN `{$ci}` ci ON ci.email <> '' AND MD5(LOWER(TRIM(ci.email))) = sub.email_hash WHERE sub.email_hash <> '' AND sub.eponymous_id = 0 AND sub.merged_into = 0 AND ci.eponymous_id > 0 LIMIT %d", $limit ), ARRAY_A ); $n = 0; foreach ( (array) $rows as $r ) { self::stamp_identity( (int) $r['subject_id'], (int) $r['wp_user_id'], '', (int) $r['eponymous_id'] ); $n++; } return $n; } private static function subjects_table() { global $wpdb; return $wpdb->prefix . 'aieo_subjects'; } private static function session_identity_table() { global $wpdb; return $wpdb->prefix . 'aieo_session_identity'; } private static function ci_table() { global $wpdb; return $wpdb->prefix . 'aieo_customer_intelligence'; } private static function table_exists( $table ) { global $wpdb; if ( function_exists( 'aieo_dmm_table_exists' ) ) { return (bool) aieo_dmm_table_exists( $table ); } return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; } private static function tables_exist() { return self::table_exists( self::subjects_table() ) && self::table_exists( self::session_identity_table() ); } } if ( defined( 'WP_CLI' ) && WP_CLI ) { class AIEO_CLI_Identity { /** * @when after_wp_load */ public function backfill( $args, $assoc ) { $batch = max( 1, (int) ( $assoc['batch'] ?? AIEO_Subject_Resolver::BACKFILL_BATCH ) ); $max = max( 0, (int) ( $assoc['max'] ?? 0 ) ); $dry = isset( $assoc['dry-run'] ); $pending = AIEO_Subject_Resolver::backfill_pending_count(); WP_CLI::log( sprintf( 'Pending (session,order) pairs with an unresolved subject: %s', number_format_i18n( $pending ) ) ); if ( $dry ) { WP_CLI::success( 'Dry run — nothing written.' ); return; } if ( $pending < 1 ) { WP_CLI::success( 'Nothing to backfill.' ); return; } $totals = array( 'scanned' => 0, 'resolved' => 0, 'skipped' => 0 ); $rounds = 0; do { $res = AIEO_Subject_Resolver::backfill_from_orders( $batch ); $totals['scanned'] += $res['scanned']; $totals['resolved'] += $res['resolved']; $totals['skipped'] += $res['skipped']; $rounds++; WP_CLI::log( sprintf( ' pass %d: scanned %d, resolved %d, skipped %d', $rounds, $res['scanned'], $res['resolved'], $res['skipped'] ) ); if ( $res['resolved'] < 1 ) { break; } if ( $max > 0 && $totals['scanned'] >= $max ) { break; } } while ( ! empty( $res['remaining'] ) ); $upgraded = AIEO_Subject_Resolver::reresolve_known_emails( 5000 ); WP_CLI::success( sprintf( 'Backfill done in %d pass(es): scanned %s, resolved %s, skipped %s, email->person upgrades %s. Remaining: %s.', $rounds, number_format_i18n( $totals['scanned'] ), number_format_i18n( $totals['resolved'] ), number_format_i18n( $totals['skipped'] ), number_format_i18n( $upgraded ), number_format_i18n( AIEO_Subject_Resolver::backfill_pending_count() ) ) ); } /** * @when after_wp_load */ public function status( $args, $assoc ) { global $wpdb; $sub = $wpdb->prefix . 'aieo_subjects'; $ci = $wpdb->prefix . 'aieo_customer_intelligence'; $row = $wpdb->get_row( "SELECT COUNT(*) total, SUM(wp_user_id > 0 OR eponymous_id > 0) resolved, SUM(merged_into > 0) merged FROM `{$sub}`", ARRAY_A ); $people = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ci}`" ); $total = (int) ( $row['total'] ?? 0 ); $res = (int) ( $row['resolved'] ?? 0 ); WP_CLI::log( sprintf( 'subjects : %s', number_format_i18n( $total ) ) ); WP_CLI::log( sprintf( ' resolved : %s (%.2f%%)', number_format_i18n( $res ), $total ? 100 * $res / $total : 0 ) ); WP_CLI::log( sprintf( ' merged (tombstone): %s', number_format_i18n( (int) ( $row['merged'] ?? 0 ) ) ) ); WP_CLI::log( sprintf( 'CDP people : %s', number_format_i18n( $people ) ) ); WP_CLI::log( sprintf( ' reachable now : %.2f%% of the CDP', $people ? 100 * $res / $people : 0 ) ); WP_CLI::log( sprintf( 'backfillable pairs : %s', number_format_i18n( AIEO_Subject_Resolver::backfill_pending_count() ) ) ); } } WP_CLI::add_command( 'aieo identity', 'AIEO_CLI_Identity' ); }