true. * * @var array */ private $expired = array(); /** * Last action seen per pair in this request (same-request duplicate guard). * * @var array */ private $last_action = array(); /** * Constructor. * * @param EVLFL_Event_Manager $manager Manager. */ public function __construct( $manager ) { $this->manager = $manager; } /** * Whether LearnDash looks available. * * @return bool */ public static function is_available() { return defined( 'LEARNDASH_VERSION' ) || class_exists( 'SFWD_LMS' ) || function_exists( 'ld_update_course_access' ); } /** * LearnDash version string, if known. * * @return string */ public static function version() { return defined( 'LEARNDASH_VERSION' ) ? (string) LEARNDASH_VERSION : ''; } /** * Init: register now if LearnDash is loaded, otherwise retry once LearnDash becomes available. * * @return void */ public function init() { add_action( 'shutdown', array( $this, 'flush' ), 5 ); if ( ! $this->maybe_register() ) { add_action( 'init', array( $this, 'maybe_register' ), 0 ); add_action( 'wp_loaded', array( $this, 'maybe_register' ), 0 ); } } /** * Register LearnDash listeners exactly once, only when LearnDash is present. * * @return bool */ public function maybe_register() { if ( $this->registered ) { return true; } if ( ! self::is_available() ) { return false; } add_action( 'learndash_update_course_access', array( $this, 'on_course_access_updated' ), 10, 4 ); add_action( 'learndash_user_course_access_expired', array( $this, 'on_course_access_expired' ), 10, 2 ); $this->registered = true; return true; } /** * Whether hooks are registered. * * @return bool */ public function is_registered() { return $this->registered; } /** * Enrollment/unenrollment hook. Never lets an error escape. * * @param int $user_id User ID. * @param int $course_id Course ID. * @param string|null $course_access_list Legacy list (null when not used). * @param bool $remove True when access was removed. * @return void */ public function on_course_access_updated( $user_id = 0, $course_id = 0, $course_access_list = null, $remove = false ) { try { $user_id = absint( $user_id ); $course_id = absint( $course_id ); $action = $remove ? EVLFL_Event::ACTION_UNENROLLED : EVLFL_Event::ACTION_ENROLLED; $key = $user_id . ':' . $course_id; if ( isset( $this->last_action[ $key ] ) && $this->last_action[ $key ] === $action ) { return; // LearnDash fired the same change twice in one request. } $pending = $this->manager->capture( array( 'user_id' => $user_id, 'course_id' => $course_id, 'action' => $action, 'hook' => 'learndash_update_course_access', 'state' => $this->snapshot_state( $user_id, $course_id ), 'metadata' => array( 'legacy_access_list' => null !== $course_access_list, 'learndash_version' => self::version(), ), ) ); if ( null === $pending ) { return; } $this->last_action[ $key ] = $action; $pending['key'] = $key; $this->queue[] = $pending; if ( count( $this->queue ) >= self::MAX_QUEUE ) { $this->flush(); } } catch ( \Throwable $e ) { $this->debug( $e ); } } /** * Expiry hook: remembers that LearnDash reported this pair as expired. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return void */ public function on_course_access_expired( $user_id = 0, $course_id = 0 ) { try { $this->expired[ absint( $user_id ) . ':' . absint( $course_id ) ] = true; } catch ( \Throwable $e ) { $this->debug( $e ); } } /** * Process queued events. Runs at shutdown so the expiry marker is known regardless of hook order. * * @return void */ public function flush() { try { $queue = $this->queue; $this->queue = array(); foreach ( $queue as $i => $pending ) { $extra = array( 'expired_hook_fired' => ! empty( $this->expired[ $pending['key'] ] ) ); foreach ( $queue as $j => $other ) { if ( $j === $i || $other['key'] !== $pending['key'] ) { continue; } if ( $j > $i && EVLFL_Event::ACTION_UNENROLLED === $pending['event']['action'] && EVLFL_Event::ACTION_ENROLLED === $other['event']['action'] ) { $extra['reenrolled_in_request'] = true; // Access removed, then granted again in this same request. } if ( $j < $i && EVLFL_Event::ACTION_ENROLLED === $pending['event']['action'] && EVLFL_Event::ACTION_UNENROLLED === $other['event']['action'] ) { $extra['unenrolled_earlier_in_request'] = true; } } $this->manager->process( $pending, $extra ); } } catch ( \Throwable $e ) { $this->debug( $e ); } } /** * Read-only snapshot of access state, guarded per function. * * @param int $user_id User. * @param int $course_id Course. * @return array */ private function snapshot_state( $user_id, $course_id ) { $state = array( 'expires_on' => null, 'has_access' => null, ); try { if ( function_exists( 'ld_course_access_expires_on' ) ) { $ts = ld_course_access_expires_on( $course_id, $user_id ); if ( is_numeric( $ts ) && (int) $ts > 0 ) { $state['expires_on'] = (int) $ts; } } // Deliberately NOT calling sfwd_lms_has_access(): it may run LearnDash's expiry // processing and thereby change enrollments, which this plugin must never do. $state['has_access'] = apply_filters( 'evlfl_snapshot_has_access', null, $user_id, $course_id ); } catch ( \Throwable $e ) { $this->debug( $e ); } return $state; } /** * Debug-only logging. * * @param \Throwable $e Error. * @return void */ private function debug( $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'EVL LearnDash error: ' . get_class( $e ) . ': ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } }