| @@ -25,8 +25,44 @@ | ||
| 25 | 25 | */ |
| 26 | 26 | class Wpfnl_Activator |
| 27 | 27 | { |
| 28 | 28 | |
| 29 | + /** | |
| 30 | + * Flags that the one-time canvas layout pin has already run. | |
| 31 | + * | |
| 32 | + * @since 3.12.13 | |
| 33 | + */ | |
| 34 | + const BUILDER_MODE_PINNED_OPTION = 'wpfunnels_builder_mode_pinned'; | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * Flags that a fresh install still owes the revenue report its schedule. | |
| 38 | + * | |
| 39 | + * Set while seeding on activation and cleared once Action Scheduler is | |
| 40 | + * loaded and the recurring action is queued. | |
| 41 | + * | |
| 42 | + * @since 3.14.0 | |
| 43 | + */ | |
| 44 | + const REVENUE_REPORT_PENDING_OPTION = 'wpfunnels_revenue_report_pending_schedule'; | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * Seconds to wait before re-queuing a DB update callback that reported | |
| 48 | + * more work to do. Throttles a misbehaving callback instead of letting it | |
| 49 | + * saturate the ActionScheduler queue runner. | |
| 50 | + * | |
| 51 | + * @since 3.12.13 | |
| 52 | + */ | |
| 53 | + const REQUEUE_DELAY = MINUTE_IN_SECONDS; | |
| 54 | + | |
| 55 | + /** | |
| 56 | + * One-time cleanup of the ActionScheduler rows left behind by the runaway | |
| 57 | + * update-callback loop that shipped in 3.12.12. | |
| 58 | + * | |
| 59 | + * @since 3.12.13 | |
| 60 | + */ | |
| 61 | + const RUNAWAY_PURGE_HOOK = 'wpfunnels_purge_runaway_update_actions'; | |
| 62 | + const RUNAWAY_PURGE_OPTION = 'wpfunnels_runaway_update_actions_purged'; | |
| 63 | + const RUNAWAY_PURGE_BATCH = 5000; | |
| 64 | + | |
| 29 | 65 | private static $db_updates = array( |
| 30 | 66 | '3.5.0' => array( |
| 31 | 67 | 'wpf_create_350_stat_table', |
| 32 | 68 | 'wpf_create_350_optin_entries_table' |
| @@ -33,8 +69,11 @@ | ||
| 33 | 69 | ), |
| 34 | 70 | '3.11.0' => array( |
| 35 | 71 | 'wpf_create_3110_checkout_visits_table', |
| 36 | 72 | ), |
| 73 | + '3.12.12' => array( | |
| 74 | + 'wpf_add_31212_stats_funnel_status_index', | |
| 75 | + ), | |
| 37 | 76 | ); |
| 38 | 77 | |
| 39 | 78 | |
| 40 | 79 | /** |
| @@ -42,10 +81,14 @@ | ||
| 42 | 81 | * |
| 43 | 82 | * @return void |
| 44 | 83 | */ |
| 45 | 84 | public static function init() { |
| 85 | + add_action( 'init', array( __CLASS__, 'maybe_pin_legacy_builder_mode' ), 1 ); | |
| 46 | 86 | add_action( 'init', array( __CLASS__, 'update' ), 20 ); |
| 47 | 87 | add_action('wpfunnels_run_update_callback', array( __CLASS__, 'run_update_callback' ) ); |
| 88 | + add_action( 'init', array( __CLASS__, 'maybe_schedule_runaway_purge' ), 21 ); | |
| 89 | + add_action( 'init', array( __CLASS__, 'maybe_schedule_seeded_revenue_report' ), 21 ); | |
| 90 | + add_action( self::RUNAWAY_PURGE_HOOK, array( __CLASS__, 'purge_runaway_update_actions' ) ); | |
| 48 | 91 | } |
| 49 | 92 | |
| 50 | 93 | /** |
| 51 | 94 | * Push all db updates to ActionScheduler |
| @@ -120,34 +163,212 @@ | ||
| 120 | 163 | |
| 121 | 164 | /** |
| 122 | 165 | * Triggered when a callback has ran. |
| 123 | 166 | * |
| 124 | - * @param $callback | |
| 125 | - * @param $result | |
| 167 | + * A truthy $result means the callback processed one batch and has more work | |
| 168 | + * left, so it gets queued again; a falsy $result means it finished. Callbacks | |
| 169 | + * that do all their work in one pass MUST return false — returning true from a | |
| 170 | + * single-pass callback is what produced the runaway queue fixed in 3.12.13. | |
| 171 | + * | |
| 172 | + * Two guards keep a callback that gets this wrong from flooding the queue: | |
| 173 | + * the re-queue is skipped when an identical action is already pending, and it | |
| 174 | + * is spaced out by REQUEUE_DELAY rather than being due the instant it lands. | |
| 175 | + * A genuine batched migration loses nothing from the delay; a buggy one is | |
| 176 | + * capped at one row per interval instead of saturating the queue runner. | |
| 177 | + * | |
| 178 | + * @param string $callback Callback name. | |
| 179 | + * @param bool $result Whether the callback has more work queued up. | |
| 126 | 180 | */ |
| 127 | 181 | protected static function run_update_callback_end( $callback, $result ) { |
| 128 | - if ( $result ) { | |
| 129 | - as_schedule_single_action( | |
| 130 | - time(), | |
| 131 | - 'wpfunnels_run_update_callback', | |
| 132 | - array( | |
| 133 | - 'update_callback' => $callback, | |
| 134 | - ), | |
| 135 | - 'wpfunnels-db-updates' | |
| 136 | - ); | |
| 137 | - } else { | |
| 138 | - update_option('wpfunnels_'.$callback.'_update', 'completed'); | |
| 182 | + if ( ! $result ) { | |
| 183 | + update_option( 'wpfunnels_' . $callback . '_update', 'completed' ); | |
| 184 | + return; | |
| 139 | 185 | } |
| 186 | + | |
| 187 | + $args = array( 'update_callback' => $callback ); | |
| 188 | + | |
| 189 | + if ( as_next_scheduled_action( 'wpfunnels_run_update_callback', $args, 'wpfunnels-db-updates' ) ) { | |
| 190 | + return; | |
| 191 | + } | |
| 192 | + | |
| 193 | + as_schedule_single_action( | |
| 194 | + time() + self::REQUEUE_DELAY, | |
| 195 | + 'wpfunnels_run_update_callback', | |
| 196 | + $args, | |
| 197 | + 'wpfunnels-db-updates' | |
| 198 | + ); | |
| 140 | 199 | } |
| 141 | 200 | |
| 142 | 201 | |
| 143 | 202 | |
| 144 | 203 | /** |
| 204 | + * Queue the one-time purge of runaway update-callback rows. | |
| 205 | + * | |
| 206 | + * 3.12.12 shipped a callback that reported "more work to do" on every run, | |
| 207 | + * so ActionScheduler re-queued it without pause and affected sites ended up | |
| 208 | + * with millions of rows in actionscheduler_actions. The return-value fix | |
| 209 | + * stops the loop, but it cannot remove what already accumulated, and the | |
| 210 | + * volume is far past what the Scheduled Actions screen can delete by hand. | |
| 211 | + * | |
| 212 | + * @since 3.12.13 | |
| 213 | + */ | |
| 214 | + public static function maybe_schedule_runaway_purge() { | |
| 215 | + if ( 'completed' === get_option( self::RUNAWAY_PURGE_OPTION ) ) { | |
| 216 | + return; | |
| 217 | + } | |
| 218 | + | |
| 219 | + // Front-end requests are the bulk of a site's traffic and there is no | |
| 220 | + // reason to spend a queue lookup on each one; admin and cron hits are | |
| 221 | + // frequent enough to get the chain started and to restart it if it | |
| 222 | + // ever breaks part-way through. | |
| 223 | + if ( ! is_admin() && ! wp_doing_cron() ) { | |
| 224 | + return; | |
| 225 | + } | |
| 226 | + | |
| 227 | + if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { | |
| 228 | + return; | |
| 229 | + } | |
| 230 | + | |
| 231 | + // Covers in-progress as well as pending, so a batch that is mid-flight | |
| 232 | + // does not get a duplicate queued alongside it. | |
| 233 | + if ( as_next_scheduled_action( self::RUNAWAY_PURGE_HOOK, array(), 'wpfunnels-db-updates' ) ) { | |
| 234 | + return; | |
| 235 | + } | |
| 236 | + | |
| 237 | + self::queue_purge_batch( time() ); | |
| 238 | + } | |
| 239 | + | |
| 240 | + | |
| 241 | + /** | |
| 242 | + * Delete one batch of finished update-callback rows, chaining the next | |
| 243 | + * batch until nothing is left. | |
| 244 | + * | |
| 245 | + * Only rows in a terminal state are touched. Pending and in-progress rows | |
| 246 | + * are left alone so a site part-way through a legitimate migration does not | |
| 247 | + * lose queued work. | |
| 248 | + * | |
| 249 | + * @since 3.12.13 | |
| 250 | + */ | |
| 251 | + public static function purge_runaway_update_actions() { | |
| 252 | + global $wpdb; | |
| 253 | + | |
| 254 | + $actions_table = $wpdb->prefix . 'actionscheduler_actions'; | |
| 255 | + | |
| 256 | + if ( ! self::table_exists( $actions_table ) ) { | |
| 257 | + self::finish_runaway_purge(); | |
| 258 | + return; | |
| 259 | + } | |
| 260 | + | |
| 261 | + $ids = $wpdb->get_col( $wpdb->prepare( | |
| 262 | + "SELECT action_id FROM {$actions_table} WHERE hook = %s AND status IN ( %s, %s, %s ) LIMIT %d", | |
| 263 | + 'wpfunnels_run_update_callback', | |
| 264 | + 'complete', | |
| 265 | + 'canceled', | |
| 266 | + 'failed', | |
| 267 | + self::RUNAWAY_PURGE_BATCH | |
| 268 | + ) ); // phpcs:ignore | |
| 269 | + | |
| 270 | + if ( empty( $ids ) ) { | |
| 271 | + self::finish_runaway_purge(); | |
| 272 | + return; | |
| 273 | + } | |
| 274 | + | |
| 275 | + $ids = array_map( 'absint', $ids ); | |
| 276 | + $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); | |
| 277 | + $logs_table = $wpdb->prefix . 'actionscheduler_logs'; | |
| 278 | + | |
| 279 | + // Logs first: dropping the actions before their logs would strand the | |
| 280 | + // log rows with no id left to match them on. | |
| 281 | + if ( self::table_exists( $logs_table ) ) { | |
| 282 | + $wpdb->query( $wpdb->prepare( "DELETE FROM {$logs_table} WHERE action_id IN ({$placeholders})", $ids ) ); // phpcs:ignore | |
| 283 | + } | |
| 284 | + | |
| 285 | + $wpdb->query( $wpdb->prepare( "DELETE FROM {$actions_table} WHERE action_id IN ({$placeholders})", $ids ) ); // phpcs:ignore | |
| 286 | + | |
| 287 | + // Chain the next batch by hand rather than using a recurring action. | |
| 288 | + // ActionScheduler re-creates a recurring action after the callback | |
| 289 | + // returns, from the action object it read before execution, so a | |
| 290 | + // recurring purge could not switch itself off from in here — it would | |
| 291 | + // keep queueing a fresh row every interval long after the work ran out. | |
| 292 | + self::queue_purge_batch( time() + MINUTE_IN_SECONDS ); | |
| 293 | + } | |
| 294 | + | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * Queue a single purge batch, unless one is already waiting. | |
| 298 | + * | |
| 299 | + * The pending check is deliberately narrower than as_next_scheduled_action(), | |
| 300 | + * which reports true for the in-progress action as well. Called from inside | |
| 301 | + * a running batch, that would always short-circuit and the chain would stop | |
| 302 | + * after its first pass. | |
| 303 | + * | |
| 304 | + * @param int $timestamp When the batch should run. | |
| 305 | + * | |
| 306 | + * @since 3.12.13 | |
| 307 | + */ | |
| 308 | + protected static function queue_purge_batch( $timestamp ) { | |
| 309 | + if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_get_scheduled_actions' ) ) { | |
| 310 | + return; | |
| 311 | + } | |
| 312 | + | |
| 313 | + $pending = as_get_scheduled_actions( | |
| 314 | + array( | |
| 315 | + 'hook' => self::RUNAWAY_PURGE_HOOK, | |
| 316 | + 'status' => ActionScheduler_Store::STATUS_PENDING, | |
| 317 | + 'group' => 'wpfunnels-db-updates', | |
| 318 | + 'per_page' => 1, | |
| 319 | + ), | |
| 320 | + 'ids' | |
| 321 | + ); | |
| 322 | + | |
| 323 | + if ( ! empty( $pending ) ) { | |
| 324 | + return; | |
| 325 | + } | |
| 326 | + | |
| 327 | + as_schedule_single_action( $timestamp, self::RUNAWAY_PURGE_HOOK, array(), 'wpfunnels-db-updates' ); | |
| 328 | + } | |
| 329 | + | |
| 330 | + | |
| 331 | + /** | |
| 332 | + * Mark the purge done and make sure no further batch is queued. | |
| 333 | + * | |
| 334 | + * @since 3.12.13 | |
| 335 | + */ | |
| 336 | + protected static function finish_runaway_purge() { | |
| 337 | + update_option( self::RUNAWAY_PURGE_OPTION, 'completed' ); | |
| 338 | + | |
| 339 | + // Nothing should be queued at this point, since a batch only chains a | |
| 340 | + // successor when it actually deleted something. Swept anyway so a row | |
| 341 | + // left over from an interrupted run cannot keep the chain alive. | |
| 342 | + if ( function_exists( 'as_unschedule_all_actions' ) ) { | |
| 343 | + as_unschedule_all_actions( self::RUNAWAY_PURGE_HOOK, array(), 'wpfunnels-db-updates' ); | |
| 344 | + } | |
| 345 | + } | |
| 346 | + | |
| 347 | + | |
| 348 | + /** | |
| 349 | + * Whether a database table exists. | |
| 350 | + * | |
| 351 | + * @param string $table Fully prefixed table name. | |
| 352 | + * @return bool | |
| 353 | + * | |
| 354 | + * @since 3.12.13 | |
| 355 | + */ | |
| 356 | + protected static function table_exists( $table ) { | |
| 357 | + global $wpdb; | |
| 358 | + | |
| 359 | + return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); // phpcs:ignore | |
| 360 | + } | |
| 361 | + | |
| 362 | + | |
| 363 | + /** | |
| 145 | 364 | * Initiate the activation process |
| 146 | 365 | * |
| 147 | 366 | * @since 1.0.0 |
| 148 | 367 | */ |
| 149 | 368 | public static function activate(){ |
| 369 | + $is_new_install = self::is_new_install(); // capture before update_wpfunnles_version() sets the flag | |
| 370 | + | |
| 150 | 371 | set_transient( 'wpfunnels_just_activated', true, 60 ); |
| 151 | 372 | self::set_wpfunnels_activation_transients(); |
| 152 | 373 | |
| 153 | 374 | // added from version 3.1.7 |
| @@ -156,8 +377,13 @@ | ||
| 156 | 377 | self::update_wpfunnles_version(); |
| 157 | 378 | self::update_wpfunnels_db_version(); |
| 158 | 379 | self::update_installed_time(); |
| 159 | 380 | |
| 381 | + if ( $is_new_install ) { | |
| 382 | + self::seed_default_builder_mode(); | |
| 383 | + self::seed_default_notification_settings(); | |
| 384 | + } | |
| 385 | + | |
| 160 | 386 | // add funnel type meta |
| 161 | 387 | Wpfnl_functions::add_type_meta(); |
| 162 | 388 | } |
| 163 | 389 | |
| @@ -198,8 +424,121 @@ | ||
| 198 | 424 | return is_null(get_site_option('wpfunnels_version', null)); |
| 199 | 425 | } |
| 200 | 426 | |
| 201 | 427 | /** |
| 428 | + * Seed funnel_builder_mode for brand-new installs. | |
| 429 | + * | |
| 430 | + * Writes the current default explicitly so the value in the database always | |
| 431 | + * reflects a decision, and marks the legacy pin as handled — a fresh install | |
| 432 | + * has no old default to preserve. | |
| 433 | + * | |
| 434 | + * @since 3.11.0 | |
| 435 | + */ | |
| 436 | + private static function seed_default_builder_mode() | |
| 437 | + { | |
| 438 | + $general_settings = get_option( '_wpfunnels_general_settings', array() ); | |
| 439 | + if ( ! isset( $general_settings['funnel_builder_mode'] ) ) { | |
| 440 | + $general_settings['funnel_builder_mode'] = Wpfnl_functions::DEFAULT_FUNNEL_BUILDER_MODE; | |
| 441 | + update_option( '_wpfunnels_general_settings', $general_settings ); | |
| 442 | + } | |
| 443 | + | |
| 444 | + update_option( self::BUILDER_MODE_PINNED_OPTION, 'yes' ); | |
| 445 | + } | |
| 446 | + | |
| 447 | + /** | |
| 448 | + * Seed the notification settings for brand-new installs. | |
| 449 | + * | |
| 450 | + * The store revenue report is on out of the box from 3.14.0, but only for | |
| 451 | + * sites created from that version onward: the choice is written into the | |
| 452 | + * database here rather than flipped in the fallback that | |
| 453 | + * Wpfnl_functions::get_notification_settings() applies, so an existing site | |
| 454 | + * — which has either its own saved settings or no row at all — is left | |
| 455 | + * exactly as it was and never starts mailing after an update. | |
| 456 | + * | |
| 457 | + * A site that somehow already holds notification settings is skipped, so | |
| 458 | + * this can never overwrite a stored preference. | |
| 459 | + * | |
| 460 | + * @since 3.14.0 | |
| 461 | + */ | |
| 462 | + private static function seed_default_notification_settings() | |
| 463 | + { | |
| 464 | + $stored = get_option( '_wpfunnels_notification_settings', array() ); | |
| 465 | + if ( ! empty( $stored ) ) { | |
| 466 | + return; | |
| 467 | + } | |
| 468 | + | |
| 469 | + update_option( '_wpfunnels_notification_settings', Wpfnl_functions::get_new_install_notification_settings() ); | |
| 470 | + | |
| 471 | + // Action Scheduler is not dependable during activation, so the actual | |
| 472 | + // queueing waits for a normal request on `init`. | |
| 473 | + update_option( self::REVENUE_REPORT_PENDING_OPTION, 'yes' ); | |
| 474 | + } | |
| 475 | + | |
| 476 | + | |
| 477 | + /** | |
| 478 | + * Queue the seeded revenue report once Action Scheduler is available. | |
| 479 | + * | |
| 480 | + * Only runs for an install that was just seeded — the flag is set nowhere | |
| 481 | + * else — so no existing site's schedule is touched. If the admin saved the | |
| 482 | + * settings before this ran, that save has already queued the action and this | |
| 483 | + * only clears the flag. | |
| 484 | + * | |
| 485 | + * @since 3.14.0 | |
| 486 | + */ | |
| 487 | + public static function maybe_schedule_seeded_revenue_report() | |
| 488 | + { | |
| 489 | + if ( 'yes' !== get_option( self::REVENUE_REPORT_PENDING_OPTION ) ) { | |
| 490 | + return; | |
| 491 | + } | |
| 492 | + | |
| 493 | + if ( ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { | |
| 494 | + return; | |
| 495 | + } | |
| 496 | + | |
| 497 | + $settings = Wpfnl_functions::get_notification_settings(); | |
| 498 | + | |
| 499 | + if ( 'yes' === $settings['enable_revenue_report'] && ! Wpfnl_functions::is_revenue_report_scheduled() ) { | |
| 500 | + Wpfnl_functions::schedule_revenue_report_email( $settings ); | |
| 501 | + } | |
| 502 | + | |
| 503 | + delete_option( self::REVENUE_REPORT_PENDING_OPTION ); | |
| 504 | + } | |
| 505 | + | |
| 506 | + | |
| 507 | + /** | |
| 508 | + * Keep pre-existing sites on the horizontal canvas. | |
| 509 | + * | |
| 510 | + * The default canvas layout became vertical in 3.12.13. A site that has been | |
| 511 | + * running without a stored funnel_builder_mode has been on the old horizontal | |
| 512 | + * default all along, so flipping the fallback would silently rearrange every | |
| 513 | + * canvas its users know. This writes horizontal in explicitly, once, leaving | |
| 514 | + * the new default to apply only to installs created after the change. Users | |
| 515 | + * opt in to vertical from Settings or the canvas view switcher. | |
| 516 | + * | |
| 517 | + * Runs synchronously on `init` rather than through the Action Scheduler db | |
| 518 | + * updates, because a queued job could land after the user has already opened | |
| 519 | + * the builder and seen the wrong layout. | |
| 520 | + * | |
| 521 | + * @since 3.12.13 | |
| 522 | + */ | |
| 523 | + public static function maybe_pin_legacy_builder_mode() | |
| 524 | + { | |
| 525 | + if ( 'yes' === get_option( self::BUILDER_MODE_PINNED_OPTION ) ) { | |
| 526 | + return; | |
| 527 | + } | |
| 528 | + | |
| 529 | + $general_settings = get_option( '_wpfunnels_general_settings', array() ); | |
| 530 | + $general_settings = is_array( $general_settings ) ? $general_settings : array(); | |
| 531 | + | |
| 532 | + if ( ! isset( $general_settings['funnel_builder_mode'] ) ) { | |
| 533 | + $general_settings['funnel_builder_mode'] = 'horizontal'; | |
| 534 | + update_option( '_wpfunnels_general_settings', $general_settings ); | |
| 535 | + } | |
| 536 | + | |
| 537 | + update_option( self::BUILDER_MODE_PINNED_OPTION, 'yes' ); | |
| 538 | + } | |
| 539 | + | |
| 540 | + /** | |
| 202 | 541 | * Update db version to current |
| 203 | 542 | * |
| 204 | 543 | * @param null $version |
| 205 | 544 | * |
| @@ -305,8 +644,15 @@ | ||
| 305 | 644 | date_created datetime DEFAULT '0000-00-00 00:00:00' NOT NULL, |
| 306 | 645 | PRIMARY KEY (id) |
| 307 | 646 | ) $charset_collate;"; |
| 308 | 647 | dbDelta( $sql ); |
| 648 | + | |
| 649 | + /** | |
| 650 | + * Create the AI copilot conversation and message tables. | |
| 651 | + * | |
| 652 | + * @since 3.13.0 | |
| 653 | + */ | |
| 654 | + \WPFunnels\AI\ConversationStore::createTables(); | |
| 309 | 655 | } |
| 310 | 656 | |
| 311 | 657 | |
| 312 | 658 | /** |