From 630cb666e268245b2279eb208b894fcd3a24d4a4 Mon Sep 17 00:00:00 2001 From: Andrew Lima Date: Thu, 2 Apr 2026 12:48:32 +0200 Subject: [PATCH 1/4] Batch enrollment * FEATURE: Added support for batch enrollment for course integrations that use "enrollment". --- includes/batch-enrollment.php | 242 ++++++++++++++++++++++++ includes/common.php | 2 +- includes/courses.php | 2 +- includes/lessons.php | 6 +- includes/modules/default.php | 2 +- includes/modules/learndash.php | 29 ++- includes/modules/lifterlms.php | 49 +++-- includes/modules/senseilms.php | 40 +++- includes/modules/tutorlms.php | 51 +++-- includes/post-types/courses.php | 4 +- includes/progress.php | 8 +- includes/shortcodes/courses-outline.php | 2 +- includes/updates/upgrade_1_3.php | 2 +- js/admin.js | 4 +- pmpro-courses.php | 1 + 15 files changed, 398 insertions(+), 46 deletions(-) create mode 100644 includes/batch-enrollment.php diff --git a/includes/batch-enrollment.php b/includes/batch-enrollment.php new file mode 100644 index 0000000..d3bab1a --- /dev/null +++ b/includes/batch-enrollment.php @@ -0,0 +1,242 @@ +post_type !== $post_type || $post->post_status !== 'publish' ) { + return; + } + + // Get the level IDs currently associated with this course. + $current_levels = self::get_level_ids_for_post( $post_id ); + if ( empty( $current_levels ) ) { + return; + } + + // Get level IDs we have already processed for retroactive enrollment. + $processed_levels = get_post_meta( $post_id, self::PROCESSED_LEVELS_META, true ); + if ( ! is_array( $processed_levels ) ) { + $processed_levels = array(); + } + + // Only act on newly associated levels. + $new_levels = array_values( array_diff( $current_levels, $processed_levels ) ); + if ( empty( $new_levels ) ) { + return; + } + + self::schedule( $post_id, $new_levels, $module_slug ); + + // Mark all current levels as processed so future saves don't re-queue. + update_post_meta( $post_id, self::PROCESSED_LEVELS_META, $current_levels ); + } + + /** + * Schedule the first batch task for a course. + * + * Falls back to synchronous processing if Action Scheduler is not available. + * + * @param int $course_id Course post ID. + * @param array $level_ids Level IDs to enroll members from. + * @param string $module_slug Module slug. + */ + public static function schedule( $course_id, $level_ids, $module_slug ) { + if ( empty( $level_ids ) || empty( $course_id ) ) { + return; + } + + if ( ! class_exists( 'PMPro_Action_Scheduler' ) || ! function_exists( 'as_enqueue_async_action' ) ) { + // Fallback: run synchronously (small sites, AS not available). + self::process_batch( $course_id, $level_ids, $module_slug, 0 ); + return; + } + + PMPro_Action_Scheduler::instance()->maybe_add_task( + self::AS_HOOK, + array( + array( + 'course_id' => $course_id, + 'level_ids' => $level_ids, + 'module_slug' => $module_slug, + 'offset' => 0, + ), + ), + self::AS_GROUP, + null, + true // run_asap — enqueue for immediate async execution. + ); + } + + /** + * Action Scheduler callback — unwraps task data and runs one batch. + * + * @param array $data Task data array with keys: course_id, level_ids, module_slug, offset. + */ + public static function process_batch_task( $data ) { + if ( empty( $data['course_id'] ) || empty( $data['level_ids'] ) || empty( $data['module_slug'] ) ) { + return; + } + + self::process_batch( + (int) $data['course_id'], + (array) $data['level_ids'], + (string) $data['module_slug'], + (int) $data['offset'] + ); + } + + /** + * Process one batch of enrollments. + * + * Fires `pmpro_courses_{module_slug}_retroactive_enroll_user` for each + * user in the batch. If the batch is full, chains the next batch as a + * new async task. + * + * @param int $course_id + * @param array $level_ids + * @param string $module_slug + * @param int $offset + */ + public static function process_batch( $course_id, $level_ids, $module_slug, $offset ) { + $user_ids = self::get_active_members( $level_ids, self::BATCH_SIZE, $offset ); + + if ( empty( $user_ids ) ) { + return; + } + + foreach ( $user_ids as $user_id ) { + /** + * Fires to enroll a single user in a course retroactively. + * + * Each LMS module hooks into this to perform its own enrollment call. + * The hook name is: pmpro_courses_{module_slug}_retroactive_enroll_user + * + * @param int $user_id The user to enroll. + * @param int $course_id The course to enroll them in. + */ + do_action( "pmpro_courses_{$module_slug}_retroactive_enroll_user", (int) $user_id, (int) $course_id ); + } + + // If the batch was full there may be more users — chain the next batch. + if ( count( $user_ids ) >= self::BATCH_SIZE && function_exists( 'as_enqueue_async_action' ) ) { + as_enqueue_async_action( + self::AS_HOOK, + array( + array( + 'course_id' => $course_id, + 'level_ids' => $level_ids, + 'module_slug' => $module_slug, + 'offset' => $offset + self::BATCH_SIZE, + ), + ), + self::AS_GROUP + ); + } + } + + /** + * Get the PMPro membership level IDs associated with a post. + * + * @param int $post_id + * @return array Level IDs (integers as strings from DB). + */ + public static function get_level_ids_for_post( $post_id ) { + global $wpdb; + + return $wpdb->get_col( + $wpdb->prepare( + "SELECT membership_id FROM {$wpdb->pmpro_memberships_pages} WHERE page_id = %d", + $post_id + ) + ); + } + + /** + * Get active member user IDs for a set of level IDs, paginated. + * + * @param array $level_ids + * @param int $limit + * @param int $offset + * @return array User IDs. + */ + private static function get_active_members( $level_ids, $limit, $offset ) { + global $wpdb; + + if ( empty( $level_ids ) ) { + return array(); + } + + $level_ids = array_map( 'intval', $level_ids ); + sort( $level_ids ); + $placeholders = implode( ', ', array_fill( 0, count( $level_ids ), '%d' ) ); + $args = array_merge( $level_ids, array( $limit, $offset ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = "SELECT DISTINCT user_id + FROM {$wpdb->pmpro_memberships_users} + WHERE membership_id IN ($placeholders) + AND status = 'active' + ORDER BY user_id + LIMIT %d OFFSET %d"; + + return $wpdb->get_col( $wpdb->prepare( $sql, $args ) ); + } +} + +PMPro_Courses_Batch_Enrollment::init(); diff --git a/includes/common.php b/includes/common.php index 3f557be..63fee62 100644 --- a/includes/common.php +++ b/includes/common.php @@ -423,7 +423,7 @@ function pmpro_courses_get_lessons_html( $course_id ) { * Get the lessons dropdown HTML with all PMPro lessons that are "available" * This is used for the the lesson settings. * - * @since TBD + * @since 2.0 * */ function pmpro_courses_lessons_settings( $exclude_lessons = array(), $parent_id = 0 ) { diff --git a/includes/courses.php b/includes/courses.php index 25d72e0..e07ae94 100644 --- a/includes/courses.php +++ b/includes/courses.php @@ -134,7 +134,7 @@ function pmpro_courses_update_course_callback() { /** * Ajax function to allow creation and assignment of a draft lesson. * - * @since TBD + * @since 2.0 */ function pmpro_courses_create_lesson_cb() { diff --git a/includes/lessons.php b/includes/lessons.php index 6959663..fd64839 100644 --- a/includes/lessons.php +++ b/includes/lessons.php @@ -145,7 +145,7 @@ function pmpro_courses_lessons_pre_get_posts_table_sorting( $query ) { /** * Add a "Course" dropdown filter to the Lessons list table. * - * @since TBD + * @since 2.0 */ function pmpro_courses_lessons_filter_dropdown() { // Only show on the pmpro_lesson list screen. @@ -184,7 +184,7 @@ function pmpro_courses_lessons_filter_dropdown() { /** * Apply the Course filter to the Lessons query based on the dropdown. * - * @since TBD + * @since 2.0 */ function pmpro_courses_lessons_filter_query( WP_Query $query ) { if ( ! is_admin() || ! $query->is_main_query() ) { @@ -207,7 +207,7 @@ function pmpro_courses_lessons_filter_query( WP_Query $query ) { /** * Bypass any level restrictions for a PMPro Lesson CPT and mark it as "Free/Public" * - * @since TBD + * @since 2.0 */ function pmpro_lessons_bypass_check($hasaccess, $post, $user, $levels) { diff --git a/includes/modules/default.php b/includes/modules/default.php index dcfd6e5..38a6e15 100644 --- a/includes/modules/default.php +++ b/includes/modules/default.php @@ -113,7 +113,7 @@ public function init_active() { /** * Load the Member Edit Panel if the class exists. * - * @since TBD + * @since 2.0 */ static public function pmpro_courses_pmpro_member_edit_panels( $panels ) { diff --git a/includes/modules/learndash.php b/includes/modules/learndash.php index 3d84c7a..ed3f23b 100644 --- a/includes/modules/learndash.php +++ b/includes/modules/learndash.php @@ -30,7 +30,11 @@ public function init_active() { add_filter( 'pmpro_has_membership_access_filter', array( 'PMPro_Courses_LearnDash', 'pmpro_has_membership_access_filter' ), 10, 4 ); add_action( 'template_redirect', array( 'PMPro_Courses_LearnDash', 'template_redirect' ) ); add_filter( 'pmpro_membership_content_filter', array( 'PMPro_Courses_LearnDash', 'pmpro_membership_content_filter' ), 10, 2 ); - add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_LearnDash', 'pmpro_after_all_membership_level_changes' ) ); + add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_LearnDash', 'pmpro_after_all_membership_level_changes' ) ); + + // Retroactive batch enrollment when a course is published with level associations. + add_action( 'save_post', array( 'PMPro_Courses_LearnDash', 'on_course_save' ), 20 ); + add_action( 'pmpro_courses_learndash_retroactive_enroll_user', array( 'PMPro_Courses_LearnDash', 'retroactive_enroll_user' ), 10, 2 ); } /** @@ -207,6 +211,29 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } } + /** + * Trigger retroactive enrollment when a LearnDash course is saved as published. + * + * Runs at save_post priority 20 so PMPro has already persisted level associations. + * + * @param int $post_id The saved post ID. + */ + public static function on_course_save( $post_id ) { + PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'sfwd-courses', 'learndash' ); + } + + /** + * Enroll a single user in a LearnDash course during retroactive batch processing. + * + * @param int $user_id User to enroll. + * @param int $course_id LearnDash course post ID. + */ + public static function retroactive_enroll_user( $user_id, $course_id ) { + if ( ! ld_course_check_user_access( $course_id, $user_id ) ) { + ld_update_course_access( $user_id, $course_id ); + } + } + /** * Get courses associated with a level. */ diff --git a/includes/modules/lifterlms.php b/includes/modules/lifterlms.php index 7c3070a..f3e83d7 100644 --- a/includes/modules/lifterlms.php +++ b/includes/modules/lifterlms.php @@ -29,6 +29,10 @@ public function init_active() { add_filter( 'pmpro_membership_content_filter', array( 'PMPro_Courses_LifterLMS', 'pmpro_membership_content_filter' ), 10, 2 ); add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_LifterLMS', 'pmpro_after_all_membership_level_changes' ) ); + + // Retroactive batch enrollment when a course is published with level associations. + add_action( 'save_post', array( 'PMPro_Courses_LifterLMS', 'on_course_save' ), 20 ); + add_action( 'pmpro_courses_lifterlms_retroactive_enroll_user', array( 'PMPro_Courses_LifterLMS', 'retroactive_enroll_user' ), 10, 2 ); } /** @@ -86,37 +90,60 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } } + /** + * Trigger retroactive enrollment when a LifterLMS course is saved as published. + * + * Runs at save_post priority 20 so PMPro has already persisted level associations. + * + * @param int $post_id The saved post ID. + */ + public static function on_course_save( $post_id ) { + PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'course', 'lifterlms' ); + } + + /** + * Enroll a single user in a LifterLMS course during retroactive batch processing. + * + * @param int $user_id User to enroll. + * @param int $course_id LifterLMS course post ID. + */ + public static function retroactive_enroll_user( $user_id, $course_id ) { + if ( ! llms_is_user_enrolled( $user_id, $course_id ) ) { + llms_enroll_student( $user_id, $course_id ); + } + } + /** * Get courses associated with a level. */ public static function get_courses_for_levels( $level_ids ) { global $wpdb; - + // In case a level object was passed in. if ( is_object( $level_ids ) ) { $level_ids = $level_ids->ID; } - + // Make sure we have an array of ids. if ( ! is_array( $level_ids ) ) { $level_ids = array( $level_ids ); } - + if ( empty( $level_ids ) ) { return array(); } - + $sql = " - SELECT mp.page_id - FROM $wpdb->pmpro_memberships_pages mp - LEFT JOIN $wpdb->posts p ON mp.page_id = p.ID - WHERE mp.membership_id IN(".implode(', ', array_fill(0, count($level_ids), '%s')).") - AND p.post_type = 'course' - AND p.post_status = 'publish' + SELECT mp.page_id + FROM $wpdb->pmpro_memberships_pages mp + LEFT JOIN $wpdb->posts p ON mp.page_id = p.ID + WHERE mp.membership_id IN(".implode(', ', array_fill(0, count($level_ids), '%s')).") + AND p.post_type = 'course' + AND p.post_status = 'publish' GROUP BY mp.page_id "; $course_ids = $wpdb->get_col( call_user_func_array( array( $wpdb, 'prepare' ), array_merge( array( $sql ), $level_ids ) ) ); - + return $course_ids; } diff --git a/includes/modules/senseilms.php b/includes/modules/senseilms.php index 6796d7f..4ca5228 100644 --- a/includes/modules/senseilms.php +++ b/includes/modules/senseilms.php @@ -35,6 +35,10 @@ public function init_active() { add_action( 'template_redirect', array( 'PMPro_Courses_SenseiLMS', 'template_redirect' ) ); add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_SenseiLMS', 'pmpro_after_all_membership_level_changes' ) ); + + // Retroactive batch enrollment when a course is published with level associations. + add_action( 'save_post', array( 'PMPro_Courses_SenseiLMS', 'on_course_save' ), 20 ); + add_action( 'pmpro_courses_senseilms_retroactive_enroll_user', array( 'PMPro_Courses_SenseiLMS', 'retroactive_enroll_user' ), 10, 2 ); } /** @@ -222,6 +226,30 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } } + /** + * Trigger retroactive enrollment when a Sensei LMS course is saved as published. + * + * Runs at save_post priority 20 so PMPro has already persisted level associations. + * + * @param int $post_id The saved post ID. + */ + public static function on_course_save( $post_id ) { + PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'course', 'senseilms' ); + } + + /** + * Enroll a single user in a Sensei LMS course during retroactive batch processing. + * + * @param int $user_id User to enroll. + * @param int $course_id Sensei course post ID. + */ + public static function retroactive_enroll_user( $user_id, $course_id ) { + if ( ! Sensei_Course::is_user_enrolled( $course_id, $user_id ) ) { + $manual_enrolment_provider = Sensei_Course_Enrolment_Manager::instance()->get_manual_enrolment_provider(); + $manual_enrolment_provider->enrol_learner( $user_id, $course_id ); + } + } + /** * Get courses associated with a level. */ @@ -243,12 +271,12 @@ public static function get_courses_for_levels( $level_ids ) { } $sql = " - SELECT mp.page_id - FROM $wpdb->pmpro_memberships_pages mp - LEFT JOIN $wpdb->posts p ON mp.page_id = p.ID - WHERE mp.membership_id IN(".implode(', ', array_fill(0, count($level_ids), '%s')).") - AND p.post_type = 'course' - AND p.post_status = 'publish' + SELECT mp.page_id + FROM $wpdb->pmpro_memberships_pages mp + LEFT JOIN $wpdb->posts p ON mp.page_id = p.ID + WHERE mp.membership_id IN(".implode(', ', array_fill(0, count($level_ids), '%s')).") + AND p.post_type = 'course' + AND p.post_status = 'publish' GROUP BY mp.page_id "; $course_ids = $wpdb->get_col( call_user_func_array( array( $wpdb, 'prepare' ), array_merge( array( $sql ), $level_ids ) ) ); diff --git a/includes/modules/tutorlms.php b/includes/modules/tutorlms.php index 835cc5e..f6e5510 100644 --- a/includes/modules/tutorlms.php +++ b/includes/modules/tutorlms.php @@ -34,7 +34,11 @@ public function init_active() { add_filter( 'pmpro_membership_content_filter', array( 'PMPro_Courses_TutorLMS', 'pmpro_membership_content_filter' ), 10, 2 ); add_action( 'template_redirect', array( 'PMPro_Courses_TutorLMS', 'template_redirect' ) ); - add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_TutorLMS', 'pmpro_after_all_membership_level_changes' ) ); + add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_TutorLMS', 'pmpro_after_all_membership_level_changes' ) ); + + // Retroactive batch enrollment when a course is published with level associations. + add_action( 'save_post', array( 'PMPro_Courses_TutorLMS', 'on_course_save' ), 20 ); + add_action( 'pmpro_courses_tutorlms_retroactive_enroll_user', array( 'PMPro_Courses_TutorLMS', 'retroactive_enroll_user' ), 10, 2 ); } /** @@ -226,37 +230,60 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig return $filtered_content; // In case we don't get here. } + /** + * Trigger retroactive enrollment when a Tutor LMS course is saved as published. + * + * Runs at save_post priority 20 so PMPro has already persisted level associations. + * + * @param int $post_id The saved post ID. + */ + public static function on_course_save( $post_id ) { + PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'courses', 'tutorlms' ); + } + + /** + * Enroll a single user in a Tutor LMS course during retroactive batch processing. + * + * @param int $user_id User to enroll. + * @param int $course_id Tutor LMS course post ID. + */ + public static function retroactive_enroll_user( $user_id, $course_id ) { + if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) { + tutor_utils()->do_enroll( $user_id, 0, $course_id ); + } + } + /** * Get courses associated with a level. */ public static function get_courses_for_levels( $level_ids ) { global $wpdb; - + // In case a level object was passed in. if ( is_object( $level_ids ) ) { $level_ids = $level_ids->ID; } - + // Make sure we have an array of ids. if ( ! is_array( $level_ids ) ) { $level_ids = array( $level_ids ); } - + if ( empty( $level_ids ) ) { return array(); } - + $sql = " - SELECT mp.page_id - FROM $wpdb->pmpro_memberships_pages mp - LEFT JOIN $wpdb->posts p ON mp.page_id = p.ID - WHERE mp.membership_id IN(".implode(', ', array_fill(0, count($level_ids), '%s')).") - AND p.post_type = 'courses' - AND p.post_status = 'publish' + SELECT mp.page_id + FROM $wpdb->pmpro_memberships_pages mp + LEFT JOIN $wpdb->posts p ON mp.page_id = p.ID + WHERE mp.membership_id IN(".implode(', ', array_fill(0, count($level_ids), '%s')).") + AND p.post_type = 'courses' + AND p.post_status = 'publish' GROUP BY mp.page_id "; $course_ids = $wpdb->get_col( call_user_func_array( array( $wpdb, 'prepare' ), array_merge( array( $sql ), $level_ids ) ) ); - + return $course_ids; } diff --git a/includes/post-types/courses.php b/includes/post-types/courses.php index 620e87c..3e6163a 100644 --- a/includes/post-types/courses.php +++ b/includes/post-types/courses.php @@ -94,7 +94,7 @@ function pmpro_courses_course_cpt_define_meta_boxes() { /** * Always show the "Course Outline" metabox on a page for PMPro Courses as it's required. * - * @since TBD + * @since 2.0 */ function pmpro_courses_unhide_course_outline_meta_box( $hidden, $screen ) { if ( $screen->post_type == 'pmpro_course' ) { @@ -205,7 +205,7 @@ function pmpro_courses_get_lessons_table_html( $lessons, $section_id = 1 ){ * Save PMPro Course sections + lessons as a normalized array. * Runs only when saving the pmpro_course post type. * - * @since TBD + * @since 2.0 */ function pmpro_courses_save_course_sections( $post_id, $post, $update ) { diff --git a/includes/progress.php b/includes/progress.php index bf25a43..b8069ea 100644 --- a/includes/progress.php +++ b/includes/progress.php @@ -3,7 +3,7 @@ class PMPro_Courses_User_Progress { /** * Toggle progress for lesson completion (complete or reset/clear) * - * @since TBD + * @since 2.0 * * @param int $lesson_id The lesson ID. * @param int $user_id The user ID. Optional. @@ -82,7 +82,7 @@ public static function get_user_lesson_status( $lesson_id, $user_id = null ) { /** * Get the user's progress for a specific course. * - * @since TBD + * @since 2.0 * * @param int $course_id The course ID. * @param int $user_id The user ID. Optional. @@ -165,7 +165,7 @@ public static function get_completed_lessons_for_a_course( $course_id, $user_id /** * Get a list of courses for a user that have no progress at all. * - * @since TBD + * @since 2.0 * * @param int|null $user_id * @return array|false Array of WP_Post objects (pmpro_course) or false if no user. @@ -317,7 +317,7 @@ function pmpro_courses_toggle_lesson_progress_ajax(){ /** * Retroactively migrate any user progress from user meta to the new progress table. * - * @since TBD + * @since 2.0 */ function pmpro_courses_migrate_course_progress() { global $wpdb; diff --git a/includes/shortcodes/courses-outline.php b/includes/shortcodes/courses-outline.php index ad57c85..528943a 100644 --- a/includes/shortcodes/courses-outline.php +++ b/includes/shortcodes/courses-outline.php @@ -6,7 +6,7 @@ * - course_id: The ID(s) of the course(s) to display. Can be a single ID, an array of IDs, or a comma-separated string of IDs. * - show_course_title: Whether to display the course title. Default is true. * - * @since TBD + * @since 2.0 */ // Exit if accessed directly. diff --git a/includes/updates/upgrade_1_3.php b/includes/updates/upgrade_1_3.php index 6da123c..0107cc1 100644 --- a/includes/updates/upgrade_1_3.php +++ b/includes/updates/upgrade_1_3.php @@ -12,7 +12,7 @@ function pmpro_courses_upgrade_1_3() { /** * Create the table for progress tracking. * - * @since TBD + * @since 2.0 * @return void */ function pmpro_courses_create_progress_table() { diff --git a/js/admin.js b/js/admin.js index 1dfa492..48b8a7e 100644 --- a/js/admin.js +++ b/js/admin.js @@ -6,7 +6,7 @@ /** * Remove a lesson from the lesson table for a course. * - * @since TBD + * @since 2.0 */ function pmpro_courses_remove_lesson(lesson_id, section_id) { let $row, $section, $table; @@ -111,7 +111,7 @@ function pmpro_courses_remove_lesson(lesson_id, section_id) { * * AJAX is needed, because we need to get the WP_POST object for the lesson when building the table row. * - * @since TBD + * @since 2.0 */ function pmpro_courses_update_post(button_element) { var button = jQuery(button_element); diff --git a/pmpro-courses.php b/pmpro-courses.php index 2a59b27..c2499a7 100644 --- a/pmpro-courses.php +++ b/pmpro-courses.php @@ -23,6 +23,7 @@ require_once PMPRO_COURSES_DIR . '/includes/admin.php'; require_once PMPRO_COURSES_DIR . '/includes/settings.php'; require_once PMPRO_COURSES_DIR . '/includes/blocks.php'; +require_once PMPRO_COURSES_DIR . '/includes/batch-enrollment.php'; // Modules. function pmpro_courses_setup_modules() { From 9b4c0c2b07bad277415b11941ba94d24a8b97de7 Mon Sep 17 00:00:00 2001 From: Andrew Lima Date: Thu, 2 Apr 2026 13:13:00 +0200 Subject: [PATCH 2/4] Add daily batch processing --- includes/batch-enrollment.php | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/includes/batch-enrollment.php b/includes/batch-enrollment.php index d3bab1a..8efcc81 100644 --- a/includes/batch-enrollment.php +++ b/includes/batch-enrollment.php @@ -40,6 +40,7 @@ class PMPro_Courses_Batch_Enrollment { */ public static function init() { add_action( self::AS_HOOK, array( __CLASS__, 'process_batch_task' ) ); + add_action( 'pmpro_schedule_daily', array( __CLASS__, 'schedule_daily_retroactive_enrollment' ) ); } /** @@ -90,8 +91,6 @@ public static function maybe_schedule_for_course( $post_id, $post_type, $module_ /** * Schedule the first batch task for a course. * - * Falls back to synchronous processing if Action Scheduler is not available. - * * @param int $course_id Course post ID. * @param array $level_ids Level IDs to enroll members from. * @param string $module_slug Module slug. @@ -102,8 +101,7 @@ public static function schedule( $course_id, $level_ids, $module_slug ) { } if ( ! class_exists( 'PMPro_Action_Scheduler' ) || ! function_exists( 'as_enqueue_async_action' ) ) { - // Fallback: run synchronously (small sites, AS not available). - self::process_batch( $course_id, $level_ids, $module_slug, 0 ); + // Action Scheduler is required for retroactive batch enrollment. return; } @@ -190,6 +188,44 @@ public static function process_batch( $course_id, $level_ids, $module_slug, $off } } + /** + * Schedule retroactive enrollment for all published course posts daily. + */ + public static function schedule_daily_retroactive_enrollment() { + $module_post_types = get_option( 'pmpro_courses_modules', array() ); + + foreach ( $module_post_types as $module_slug => $post_type ) { + if ( ! pmpro_courses_is_module_active( $module_slug ) ) { + continue; + } + + foreach ( self::get_published_course_ids_for_post_type( $post_type ) as $course_id ) { + self::maybe_schedule_for_course( $course_id, $post_type, $module_slug ); + } + } + } + + /** + * Get published course post IDs for the given post type. + * + * @param string $post_type + * @return array + */ + private static function get_published_course_ids_for_post_type( $post_type ) { + global $wpdb; + + return $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT p.ID + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->pmpro_memberships_pages} mp ON mp.page_id = p.ID + WHERE p.post_type = %s + AND p.post_status = 'publish'", + $post_type + ) + ); + } + /** * Get the PMPro membership level IDs associated with a post. * @@ -239,4 +275,6 @@ private static function get_active_members( $level_ids, $limit, $offset ) { } } -PMPro_Courses_Batch_Enrollment::init(); +if ( class_exists( 'PMPro_Action_Scheduler' ) && function_exists( 'as_enqueue_async_action' ) ) { + PMPro_Courses_Batch_Enrollment::init(); +} From 05903b2a8030b846ca931506faa8e377521fa2c7 Mon Sep 17 00:00:00 2001 From: Andrew Lima Date: Thu, 9 Apr 2026 12:54:06 +0200 Subject: [PATCH 3/4] Minor changes based on Flints feedback --- includes/batch-enrollment.php | 14 ++++++++------ includes/modules/learndash.php | 5 ++++- includes/modules/lifterlms.php | 5 ++++- includes/modules/senseilms.php | 5 ++++- includes/modules/tutorlms.php | 5 ++++- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/includes/batch-enrollment.php b/includes/batch-enrollment.php index 8efcc81..0d51203 100644 --- a/includes/batch-enrollment.php +++ b/includes/batch-enrollment.php @@ -77,7 +77,7 @@ public static function maybe_schedule_for_course( $post_id, $post_type, $module_ } // Only act on newly associated levels. - $new_levels = array_values( array_diff( $current_levels, $processed_levels ) ); + $new_levels = array_values( array_diff( array_map( 'intval', $current_levels ), array_map( 'intval', $processed_levels ) ) ); if ( empty( $new_levels ) ) { return; } @@ -85,7 +85,7 @@ public static function maybe_schedule_for_course( $post_id, $post_type, $module_ self::schedule( $post_id, $new_levels, $module_slug ); // Mark all current levels as processed so future saves don't re-queue. - update_post_meta( $post_id, self::PROCESSED_LEVELS_META, $current_levels ); + update_post_meta( $post_id, self::PROCESSED_LEVELS_META, array_map( 'intval', $current_levels ) ); } /** @@ -101,7 +101,7 @@ public static function schedule( $course_id, $level_ids, $module_slug ) { } if ( ! class_exists( 'PMPro_Action_Scheduler' ) || ! function_exists( 'as_enqueue_async_action' ) ) { - // Action Scheduler is required for retroactive batch enrollment. + error_log( 'PMPro Courses: Action Scheduler not available — retroactive enrollment disabled. Requires PMPro 3.5+.' ); return; } @@ -172,7 +172,7 @@ public static function process_batch( $course_id, $level_ids, $module_slug, $off } // If the batch was full there may be more users — chain the next batch. - if ( count( $user_ids ) >= self::BATCH_SIZE && function_exists( 'as_enqueue_async_action' ) ) { + if ( count( $user_ids ) === self::BATCH_SIZE && function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::AS_HOOK, array( @@ -220,8 +220,10 @@ private static function get_published_course_ids_for_post_type( $post_type ) { FROM {$wpdb->posts} p INNER JOIN {$wpdb->pmpro_memberships_pages} mp ON mp.page_id = p.ID WHERE p.post_type = %s - AND p.post_status = 'publish'", - $post_type + AND p.post_status = 'publish' + AND p.post_modified >= %s", + $post_type, + gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) ) ) ); } diff --git a/includes/modules/learndash.php b/includes/modules/learndash.php index ed3f23b..06cd84a 100644 --- a/includes/modules/learndash.php +++ b/includes/modules/learndash.php @@ -230,7 +230,10 @@ public static function on_course_save( $post_id ) { */ public static function retroactive_enroll_user( $user_id, $course_id ) { if ( ! ld_course_check_user_access( $course_id, $user_id ) ) { - ld_update_course_access( $user_id, $course_id ); + $result = ld_update_course_access( $user_id, $course_id ); + if ( ! $result ) { + error_log( sprintf( 'PMPro Courses (LearnDash): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + } } } diff --git a/includes/modules/lifterlms.php b/includes/modules/lifterlms.php index f3e83d7..b3e7834 100644 --- a/includes/modules/lifterlms.php +++ b/includes/modules/lifterlms.php @@ -109,7 +109,10 @@ public static function on_course_save( $post_id ) { */ public static function retroactive_enroll_user( $user_id, $course_id ) { if ( ! llms_is_user_enrolled( $user_id, $course_id ) ) { - llms_enroll_student( $user_id, $course_id ); + $result = llms_enroll_student( $user_id, $course_id ); + if ( ! $result ) { + error_log( sprintf( 'PMPro Courses (LifterLMS): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + } } } diff --git a/includes/modules/senseilms.php b/includes/modules/senseilms.php index 4ca5228..fc654ad 100644 --- a/includes/modules/senseilms.php +++ b/includes/modules/senseilms.php @@ -246,7 +246,10 @@ public static function on_course_save( $post_id ) { public static function retroactive_enroll_user( $user_id, $course_id ) { if ( ! Sensei_Course::is_user_enrolled( $course_id, $user_id ) ) { $manual_enrolment_provider = Sensei_Course_Enrolment_Manager::instance()->get_manual_enrolment_provider(); - $manual_enrolment_provider->enrol_learner( $user_id, $course_id ); + $result = $manual_enrolment_provider->enrol_learner( $user_id, $course_id ); + if ( ! $result ) { + error_log( sprintf( 'PMPro Courses (Sensei): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + } } } diff --git a/includes/modules/tutorlms.php b/includes/modules/tutorlms.php index f6e5510..bcd6769 100644 --- a/includes/modules/tutorlms.php +++ b/includes/modules/tutorlms.php @@ -249,7 +249,10 @@ public static function on_course_save( $post_id ) { */ public static function retroactive_enroll_user( $user_id, $course_id ) { if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) { - tutor_utils()->do_enroll( $user_id, 0, $course_id ); + $result = tutor_utils()->do_enroll( $user_id, 0, $course_id ); + if ( ! $result ) { + error_log( sprintf( 'PMPro Courses (TutorLMS): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + } } } From 91442b9b99705b4d10cd8b2d5712636e4ff6b987 Mon Sep 17 00:00:00 2001 From: Andrew Lima Date: Wed, 2 Sep 2026 14:58:52 +0200 Subject: [PATCH 4/4] Rework retroactive enrollment to mirror core's repair pattern - Trigger off pmpro_after_updating_post_level_restrictions instead of save_post/daily cron. - Fan out one Action Scheduler task per member (halt/resume) instead of offset-chained batches. - Drop the processed-levels post meta; each per-user repair is idempotent. - Consolidate module enroll/unenroll logic into repair_user_enrollments(). - Fix Tutor LMS do_enroll() argument order. Co-Authored-By: Claude Code --- includes/batch-enrollment.php | 309 +++++++++++---------------------- includes/modules/learndash.php | 98 ++++++----- includes/modules/lifterlms.php | 99 ++++++----- includes/modules/senseilms.php | 102 +++++------ includes/modules/tutorlms.php | 100 +++++------ readme.txt | 4 + 6 files changed, 308 insertions(+), 404 deletions(-) diff --git a/includes/batch-enrollment.php b/includes/batch-enrollment.php index 0d51203..824a120 100644 --- a/includes/batch-enrollment.php +++ b/includes/batch-enrollment.php @@ -1,11 +1,14 @@ course map, so the + * work is idempotent and self-healing (no state is kept between runs). + * + * This mirrors the pattern used in PMPro core for LifterLMS streamline mode. + * Requires PMPro 3.6+ for the `pmpro_after_updating_post_level_restrictions` hook. * * This is NOT used by the default module (no enrollment concept there). */ @@ -14,269 +17,157 @@ class PMPro_Courses_Batch_Enrollment { - /** - * Number of users to process per batch. - */ - const BATCH_SIZE = 50; - /** * Action Scheduler group name. */ const AS_GROUP = 'pmpro_courses_enrollment'; /** - * Action Scheduler hook name for batch tasks. + * Action Scheduler hook that fans out one task per user. */ - const AS_HOOK = 'pmpro_courses_retroactive_enroll_batch'; + const AS_HOOK_QUEUE = 'pmpro_courses_repair_all_enrollments_callback'; /** - * Post meta key used to track which level IDs have already had - * retroactive enrollment queued for a given course. + * Action Scheduler hook that repairs a single user's enrollments. + * LMS modules hook their own repair method onto this action. */ - const PROCESSED_LEVELS_META = '_pmpro_courses_batch_enrollment_levels'; + const AS_HOOK_USER = 'pmpro_courses_repair_user_enrollments'; /** - * Register the Action Scheduler callback. + * Number of user IDs to fetch per query while building the queue. */ - public static function init() { - add_action( self::AS_HOOK, array( __CLASS__, 'process_batch_task' ) ); - add_action( 'pmpro_schedule_daily', array( __CLASS__, 'schedule_daily_retroactive_enrollment' ) ); - } + const QUEUE_CHUNK_SIZE = 250; /** - * Called from each LMS module's save_post handler. - * - * Detects newly added PMPro level associations for a published course - * and schedules batch enrollment for existing members. - * - * @param int $post_id Course post ID. - * @param string $post_type Expected course post type for the active module. - * @param string $module_slug Module slug (e.g. 'learndash', 'lifterlms'). + * Register hooks. Runs on plugins_loaded so PMPro core is guaranteed to be loaded. */ - public static function maybe_schedule_for_course( $post_id, $post_type, $module_slug ) { - // Skip autosaves and revisions. - if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { - return; - } - - $post = get_post( $post_id ); - if ( ! $post || $post->post_type !== $post_type || $post->post_status !== 'publish' ) { - return; - } - - // Get the level IDs currently associated with this course. - $current_levels = self::get_level_ids_for_post( $post_id ); - if ( empty( $current_levels ) ) { - return; - } - - // Get level IDs we have already processed for retroactive enrollment. - $processed_levels = get_post_meta( $post_id, self::PROCESSED_LEVELS_META, true ); - if ( ! is_array( $processed_levels ) ) { - $processed_levels = array(); - } - - // Only act on newly associated levels. - $new_levels = array_values( array_diff( array_map( 'intval', $current_levels ), array_map( 'intval', $processed_levels ) ) ); - if ( empty( $new_levels ) ) { + public static function init() { + if ( ! self::is_available() ) { return; } - self::schedule( $post_id, $new_levels, $module_slug ); - - // Mark all current levels as processed so future saves don't re-queue. - update_post_meta( $post_id, self::PROCESSED_LEVELS_META, array_map( 'intval', $current_levels ) ); + add_action( 'pmpro_after_updating_post_level_restrictions', array( __CLASS__, 'after_updating_post_level_restrictions' ) ); + add_action( self::AS_HOOK_QUEUE, array( __CLASS__, 'repair_all_enrollments_callback' ) ); } /** - * Schedule the first batch task for a course. + * Whether the PMPro Action Scheduler wrapper is available (PMPro 3.6+). * - * @param int $course_id Course post ID. - * @param array $level_ids Level IDs to enroll members from. - * @param string $module_slug Module slug. + * @return bool */ - public static function schedule( $course_id, $level_ids, $module_slug ) { - if ( empty( $level_ids ) || empty( $course_id ) ) { - return; - } - - if ( ! class_exists( 'PMPro_Action_Scheduler' ) || ! function_exists( 'as_enqueue_async_action' ) ) { - error_log( 'PMPro Courses: Action Scheduler not available — retroactive enrollment disabled. Requires PMPro 3.5+.' ); - return; - } - - PMPro_Action_Scheduler::instance()->maybe_add_task( - self::AS_HOOK, - array( - array( - 'course_id' => $course_id, - 'level_ids' => $level_ids, - 'module_slug' => $module_slug, - 'offset' => 0, - ), - ), - self::AS_GROUP, - null, - true // run_asap — enqueue for immediate async execution. - ); + public static function is_available() { + return class_exists( 'PMPro_Action_Scheduler' ) && function_exists( 'as_enqueue_async_action' ); } /** - * Action Scheduler callback — unwraps task data and runs one batch. + * Course post types for the active LMS modules. * - * @param array $data Task data array with keys: course_id, level_ids, module_slug, offset. + * @return array Post type slugs. */ - public static function process_batch_task( $data ) { - if ( empty( $data['course_id'] ) || empty( $data['level_ids'] ) || empty( $data['module_slug'] ) ) { - return; - } - - self::process_batch( - (int) $data['course_id'], - (array) $data['level_ids'], - (string) $data['module_slug'], - (int) $data['offset'] - ); + public static function get_course_post_types() { + /** + * Filter the post types that should trigger an enrollment repair when + * their level restrictions change. LMS modules add their course post type here. + * + * @param array $post_types Post type slugs. + */ + return array_unique( (array) apply_filters( 'pmpro_courses_enrollment_course_post_types', array() ) ); } /** - * Process one batch of enrollments. + * When the level restrictions for a course change, queue a repair for its members. * - * Fires `pmpro_courses_{module_slug}_retroactive_enroll_user` for each - * user in the batch. If the batch is full, chains the next batch as a - * new async task. - * - * @param int $course_id - * @param array $level_ids - * @param string $module_slug - * @param int $offset + * @param int $post_id The post whose level restrictions were updated. */ - public static function process_batch( $course_id, $level_ids, $module_slug, $offset ) { - $user_ids = self::get_active_members( $level_ids, self::BATCH_SIZE, $offset ); - - if ( empty( $user_ids ) ) { + public static function after_updating_post_level_restrictions( $post_id ) { + if ( ! in_array( get_post_type( $post_id ), self::get_course_post_types(), true ) ) { return; } - foreach ( $user_ids as $user_id ) { - /** - * Fires to enroll a single user in a course retroactively. - * - * Each LMS module hooks into this to perform its own enrollment call. - * The hook name is: pmpro_courses_{module_slug}_retroactive_enroll_user - * - * @param int $user_id The user to enroll. - * @param int $course_id The course to enroll them in. - */ - do_action( "pmpro_courses_{$module_slug}_retroactive_enroll_user", (int) $user_id, (int) $course_id ); - } - - // If the batch was full there may be more users — chain the next batch. - if ( count( $user_ids ) === self::BATCH_SIZE && function_exists( 'as_enqueue_async_action' ) ) { - as_enqueue_async_action( - self::AS_HOOK, - array( - array( - 'course_id' => $course_id, - 'level_ids' => $level_ids, - 'module_slug' => $module_slug, - 'offset' => $offset + self::BATCH_SIZE, - ), - ), - self::AS_GROUP - ); + // No module is listening, nothing to do. + if ( ! has_action( self::AS_HOOK_USER ) ) { + return; } - } - - /** - * Schedule retroactive enrollment for all published course posts daily. - */ - public static function schedule_daily_retroactive_enrollment() { - $module_post_types = get_option( 'pmpro_courses_modules', array() ); - - foreach ( $module_post_types as $module_slug => $post_type ) { - if ( ! pmpro_courses_is_module_active( $module_slug ) ) { - continue; - } - foreach ( self::get_published_course_ids_for_post_type( $post_type ) as $course_id ) { - self::maybe_schedule_for_course( $course_id, $post_type, $module_slug ); - } - } + self::schedule_repair_for_course( $post_id ); } /** - * Get published course post IDs for the given post type. + * Queue the fan-out task for a course. * - * @param string $post_type - * @return array + * @param int $course_id Course post ID. */ - private static function get_published_course_ids_for_post_type( $post_type ) { - global $wpdb; + public static function schedule_repair_for_course( $course_id ) { + if ( ! self::is_available() ) { + return; + } - return $wpdb->get_col( - $wpdb->prepare( - "SELECT DISTINCT p.ID - FROM {$wpdb->posts} p - INNER JOIN {$wpdb->pmpro_memberships_pages} mp ON mp.page_id = p.ID - WHERE p.post_type = %s - AND p.post_status = 'publish' - AND p.post_modified >= %s", - $post_type, - gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) ) - ) + PMPro_Action_Scheduler::instance()->maybe_add_task( + self::AS_HOOK_QUEUE, + array( 'course_id' => (int) $course_id ), + self::AS_GROUP, + null, + true ); } /** - * Get the PMPro membership level IDs associated with a post. + * Action Scheduler callback: queue one repair task per member. + * + * We queue everyone who has ever held a level (not just members of the course's + * current levels) because the hook fires after the change, so a level that was + * just removed from the course is no longer visible here and its members would + * otherwise never be unenrolled. * - * @param int $post_id - * @return array Level IDs (integers as strings from DB). + * @param int $course_id Course post ID. */ - public static function get_level_ids_for_post( $post_id ) { + public static function repair_all_enrollments_callback( $course_id ) { global $wpdb; - return $wpdb->get_col( - $wpdb->prepare( - "SELECT membership_id FROM {$wpdb->pmpro_memberships_pages} WHERE page_id = %d", - $post_id - ) - ); + // Halt Action Scheduler processing until we finish adding tasks. + PMPro_Action_Scheduler::instance()->halt(); + + $last_user_id = 0; + do { + // Keyset pagination on user_id so churn during the loop can't skip or repeat rows. + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT user_id + FROM {$wpdb->pmpro_memberships_users} + WHERE user_id > %d + ORDER BY user_id + LIMIT %d", + $last_user_id, + self::QUEUE_CHUNK_SIZE + ) + ); + + foreach ( $user_ids as $user_id ) { + self::schedule_repair_for_user( $user_id ); + $last_user_id = (int) $user_id; + } + } while ( count( $user_ids ) === self::QUEUE_CHUNK_SIZE ); + + // Resume Action Scheduler processing. + PMPro_Action_Scheduler::instance()->resume(); } /** - * Get active member user IDs for a set of level IDs, paginated. + * Queue a repair task for a single user. * - * @param array $level_ids - * @param int $limit - * @param int $offset - * @return array User IDs. + * @param int $user_id User ID. */ - private static function get_active_members( $level_ids, $limit, $offset ) { - global $wpdb; - - if ( empty( $level_ids ) ) { - return array(); + public static function schedule_repair_for_user( $user_id ) { + if ( ! self::is_available() ) { + return; } - $level_ids = array_map( 'intval', $level_ids ); - sort( $level_ids ); - $placeholders = implode( ', ', array_fill( 0, count( $level_ids ), '%d' ) ); - $args = array_merge( $level_ids, array( $limit, $offset ) ); - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $sql = "SELECT DISTINCT user_id - FROM {$wpdb->pmpro_memberships_users} - WHERE membership_id IN ($placeholders) - AND status = 'active' - ORDER BY user_id - LIMIT %d OFFSET %d"; - - return $wpdb->get_col( $wpdb->prepare( $sql, $args ) ); + PMPro_Action_Scheduler::instance()->maybe_add_task( + self::AS_HOOK_USER, + array( 'user_id' => (int) $user_id ), + self::AS_GROUP + ); } } - -if ( class_exists( 'PMPro_Action_Scheduler' ) && function_exists( 'as_enqueue_async_action' ) ) { - PMPro_Courses_Batch_Enrollment::init(); -} +// Priority 20 so this runs after pmpro_courses_setup_modules() has registered the modules. +add_action( 'plugins_loaded', array( 'PMPro_Courses_Batch_Enrollment', 'init' ), 20 ); diff --git a/includes/modules/learndash.php b/includes/modules/learndash.php index 06cd84a..072c3f5 100644 --- a/includes/modules/learndash.php +++ b/includes/modules/learndash.php @@ -32,9 +32,9 @@ public function init_active() { add_filter( 'pmpro_membership_content_filter', array( 'PMPro_Courses_LearnDash', 'pmpro_membership_content_filter' ), 10, 2 ); add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_LearnDash', 'pmpro_after_all_membership_level_changes' ) ); - // Retroactive batch enrollment when a course is published with level associations. - add_action( 'save_post', array( 'PMPro_Courses_LearnDash', 'on_course_save' ), 20 ); - add_action( 'pmpro_courses_learndash_retroactive_enroll_user', array( 'PMPro_Courses_LearnDash', 'retroactive_enroll_user' ), 10, 2 ); + // Background repair of course enrollments when a course's level restrictions change. + add_filter( 'pmpro_courses_enrollment_course_post_types', array( 'PMPro_Courses_LearnDash', 'enrollment_course_post_types' ) ); + add_action( PMPro_Courses_Batch_Enrollment::AS_HOOK_USER, array( 'PMPro_Courses_LearnDash', 'repair_user_enrollments' ) ); } /** @@ -212,31 +212,63 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } /** - * Trigger retroactive enrollment when a LearnDash course is saved as published. + * Register the LearnDash course post type for background enrollment repair. * - * Runs at save_post priority 20 so PMPro has already persisted level associations. - * - * @param int $post_id The saved post ID. + * @param array $post_types Post type slugs. + * @return array */ - public static function on_course_save( $post_id ) { - PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'sfwd-courses', 'learndash' ); + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'sfwd-courses'; + return $post_types; } /** - * Enroll a single user in a LearnDash course during retroactive batch processing. + * Reconcile a user's LearnDash course enrollments with their current membership levels. + * + * Idempotent: enrolls the user in courses for their current levels they are not yet + * in, and unenrolls them from level-restricted courses their levels no longer grant. + * Courses not tied to any level are never touched. * - * @param int $user_id User to enroll. - * @param int $course_id LearnDash course post ID. + * @param int $user_id User ID. */ - public static function retroactive_enroll_user( $user_id, $course_id ) { - if ( ! ld_course_check_user_access( $course_id, $user_id ) ) { - $result = ld_update_course_access( $user_id, $course_id ); - if ( ! $result ) { - error_log( sprintf( 'PMPro Courses (LearnDash): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + public static function repair_user_enrollments( $user_id ) { + $user_id = (int) $user_id; + if ( empty( $user_id ) ) { + return; + } + + // Courses tied to any level. + $all_level_courses = array_map( 'intval', self::get_courses_for_levels( self::get_all_level_ids() ) ); + + // Courses tied to the user's current levels. + $current_levels = wp_list_pluck( (array) pmpro_getMembershipLevelsForUser( $user_id ), 'ID' ); + $current_level_courses = array_map( 'intval', self::get_courses_for_levels( $current_levels ) ); + + // Unenroll from level-restricted courses the user's levels no longer grant. + foreach ( array_diff( $all_level_courses, $current_level_courses ) as $course_id ) { + if ( ld_course_check_user_access( $course_id, $user_id ) ) { + // True param here at the end tells it to remove. + ld_update_course_access( $user_id, $course_id, true ); + } + } + + // Enroll in courses for the user's current levels. + foreach ( $current_level_courses as $course_id ) { + if ( ! ld_course_check_user_access( $course_id, $user_id ) ) { + ld_update_course_access( $user_id, $course_id ); } } } + /** + * Get all membership level IDs. + * + * @return array + */ + private static function get_all_level_ids() { + return array_map( 'intval', wp_list_pluck( (array) pmpro_getAllLevels( true ), 'id' ) ); + } + /** * Get courses associated with a level. */ @@ -276,36 +308,8 @@ public static function get_courses_for_levels( $level_ids ) { * any associated private courses. */ public static function pmpro_after_all_membership_level_changes( $pmpro_old_user_levels ) { - foreach ( $pmpro_old_user_levels as $user_id => $old_levels ) { - // Get current courses. - $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); - if ( ! empty( $current_levels ) ) { - $current_levels = wp_list_pluck( $current_levels, 'ID' ); - } else { - $current_levels = array(); - } - $current_courses = PMPro_Courses_LearnDash::get_courses_for_levels( $current_levels ); - - // Get old courses. - $old_levels = wp_list_pluck( $old_levels, 'ID' ); - $old_courses = PMPro_Courses_LearnDash::get_courses_for_levels( $old_levels ); - - // Unenroll the user in any courses they used to have, but lost. - $courses_to_unenroll = array_diff( $old_courses, $current_courses ); - foreach( $courses_to_unenroll as $course_id ) { - if ( ld_course_check_user_access( $course_id, $user_id ) ) { - // True param here at the end tells it to remove. - ld_update_course_access( $user_id, $course_id, true ); - } - } - - // Enroll the user in any courses for their current levels. - $courses_to_enroll = array_diff( $current_courses, $old_courses ); - foreach( $courses_to_enroll as $course_id ) { - if ( ! ld_course_check_user_access( $course_id, $user_id ) ) { - ld_update_course_access( $user_id, $course_id ); - } - } + foreach ( array_keys( $pmpro_old_user_levels ) as $user_id ) { + self::repair_user_enrollments( $user_id ); } } } diff --git a/includes/modules/lifterlms.php b/includes/modules/lifterlms.php index b3e7834..47fc042 100644 --- a/includes/modules/lifterlms.php +++ b/includes/modules/lifterlms.php @@ -30,9 +30,9 @@ public function init_active() { add_filter( 'pmpro_membership_content_filter', array( 'PMPro_Courses_LifterLMS', 'pmpro_membership_content_filter' ), 10, 2 ); add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_LifterLMS', 'pmpro_after_all_membership_level_changes' ) ); - // Retroactive batch enrollment when a course is published with level associations. - add_action( 'save_post', array( 'PMPro_Courses_LifterLMS', 'on_course_save' ), 20 ); - add_action( 'pmpro_courses_lifterlms_retroactive_enroll_user', array( 'PMPro_Courses_LifterLMS', 'retroactive_enroll_user' ), 10, 2 ); + // Background repair of course enrollments when a course's level restrictions change. + add_filter( 'pmpro_courses_enrollment_course_post_types', array( 'PMPro_Courses_LifterLMS', 'enrollment_course_post_types' ) ); + add_action( PMPro_Courses_Batch_Enrollment::AS_HOOK_USER, array( 'PMPro_Courses_LifterLMS', 'repair_user_enrollments' ) ); } /** @@ -91,31 +91,62 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } /** - * Trigger retroactive enrollment when a LifterLMS course is saved as published. + * Register the LifterLMS course post type for background enrollment repair. * - * Runs at save_post priority 20 so PMPro has already persisted level associations. - * - * @param int $post_id The saved post ID. + * @param array $post_types Post type slugs. + * @return array */ - public static function on_course_save( $post_id ) { - PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'course', 'lifterlms' ); + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'course'; + return $post_types; } /** - * Enroll a single user in a LifterLMS course during retroactive batch processing. + * Reconcile a user's LifterLMS course enrollments with their current membership levels. + * + * Idempotent: enrolls the user in courses for their current levels they are not yet + * in, and unenrolls them from level-restricted courses their levels no longer grant. + * Courses not tied to any level are never touched. * - * @param int $user_id User to enroll. - * @param int $course_id LifterLMS course post ID. + * @param int $user_id User ID. */ - public static function retroactive_enroll_user( $user_id, $course_id ) { - if ( ! llms_is_user_enrolled( $user_id, $course_id ) ) { - $result = llms_enroll_student( $user_id, $course_id ); - if ( ! $result ) { - error_log( sprintf( 'PMPro Courses (LifterLMS): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + public static function repair_user_enrollments( $user_id ) { + $user_id = (int) $user_id; + if ( empty( $user_id ) ) { + return; + } + + // Courses tied to any level. + $all_level_courses = array_map( 'intval', self::get_courses_for_levels( self::get_all_level_ids() ) ); + + // Courses tied to the user's current levels. + $current_levels = wp_list_pluck( (array) pmpro_getMembershipLevelsForUser( $user_id ), 'ID' ); + $current_level_courses = array_map( 'intval', self::get_courses_for_levels( $current_levels ) ); + + // Unenroll from level-restricted courses the user's levels no longer grant. + foreach ( array_diff( $all_level_courses, $current_level_courses ) as $course_id ) { + if ( llms_is_user_enrolled( $user_id, $course_id ) ) { + llms_unenroll_student( $user_id, $course_id ); + } + } + + // Enroll in courses for the user's current levels. + foreach ( $current_level_courses as $course_id ) { + if ( ! llms_is_user_enrolled( $user_id, $course_id ) ) { + llms_enroll_student( $user_id, $course_id ); } } } + /** + * Get all membership level IDs. + * + * @return array + */ + private static function get_all_level_ids() { + return array_map( 'intval', wp_list_pluck( (array) pmpro_getAllLevels( true ), 'id' ) ); + } + /** * Get courses associated with a level. */ @@ -154,37 +185,9 @@ public static function get_courses_for_levels( $level_ids ) { * When users change levels, enroll/unenroll them from * any associated private courses. */ - public static function pmpro_after_all_membership_level_changes( $pmpro_old_user_levels ) { - foreach ( $pmpro_old_user_levels as $user_id => $old_levels ) { - // Get current courses. - $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); - if ( ! empty( $current_levels ) ) { - $current_levels = wp_list_pluck( $current_levels, 'ID' ); - } else { - $current_levels = array(); - } - $current_courses = PMPro_Courses_LifterLMS::get_courses_for_levels( $current_levels ); - - // Get old courses. - $old_levels = wp_list_pluck( $old_levels, 'ID' ); - $old_courses = PMPro_Courses_LifterLMS::get_courses_for_levels( $old_levels ); - - // Unenroll the user in any courses they used to have, but lost. - $courses_to_unenroll = array_diff( $old_courses, $current_courses ); - foreach( $courses_to_unenroll as $course_id ) { - if ( llms_is_user_enrolled( $user_id, $course_id ) ) { - // Unenroll student - llms_unenroll_student( $user_id, $course_id ); - } - } - - // Enroll the user in any courses for their current levels. - $courses_to_enroll = array_diff( $current_courses, $old_courses ); - foreach( $courses_to_enroll as $course_id ) { - if ( ! llms_is_user_enrolled( $user_id, $course_id ) ) { - llms_enroll_student( $user_id, $course_id ); - } - } + public static function pmpro_after_all_membership_level_changes( $pmpro_old_user_levels ) { + foreach ( array_keys( $pmpro_old_user_levels ) as $user_id ) { + self::repair_user_enrollments( $user_id ); } } } diff --git a/includes/modules/senseilms.php b/includes/modules/senseilms.php index fc654ad..90cfbcd 100644 --- a/includes/modules/senseilms.php +++ b/includes/modules/senseilms.php @@ -36,9 +36,9 @@ public function init_active() { add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_SenseiLMS', 'pmpro_after_all_membership_level_changes' ) ); - // Retroactive batch enrollment when a course is published with level associations. - add_action( 'save_post', array( 'PMPro_Courses_SenseiLMS', 'on_course_save' ), 20 ); - add_action( 'pmpro_courses_senseilms_retroactive_enroll_user', array( 'PMPro_Courses_SenseiLMS', 'retroactive_enroll_user' ), 10, 2 ); + // Background repair of course enrollments when a course's level restrictions change. + add_filter( 'pmpro_courses_enrollment_course_post_types', array( 'PMPro_Courses_SenseiLMS', 'enrollment_course_post_types' ) ); + add_action( PMPro_Courses_Batch_Enrollment::AS_HOOK_USER, array( 'PMPro_Courses_SenseiLMS', 'repair_user_enrollments' ) ); } /** @@ -227,32 +227,63 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } /** - * Trigger retroactive enrollment when a Sensei LMS course is saved as published. + * Register the Sensei LMS course post type for background enrollment repair. * - * Runs at save_post priority 20 so PMPro has already persisted level associations. - * - * @param int $post_id The saved post ID. + * @param array $post_types Post type slugs. + * @return array */ - public static function on_course_save( $post_id ) { - PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'course', 'senseilms' ); + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'course'; + return $post_types; } /** - * Enroll a single user in a Sensei LMS course during retroactive batch processing. + * Reconcile a user's Sensei LMS course enrollments with their current membership levels. + * + * Idempotent: enrolls the user in courses for their current levels they are not yet + * in, and unenrolls them from level-restricted courses their levels no longer grant. + * Courses not tied to any level are never touched. * - * @param int $user_id User to enroll. - * @param int $course_id Sensei course post ID. + * @param int $user_id User ID. */ - public static function retroactive_enroll_user( $user_id, $course_id ) { - if ( ! Sensei_Course::is_user_enrolled( $course_id, $user_id ) ) { - $manual_enrolment_provider = Sensei_Course_Enrolment_Manager::instance()->get_manual_enrolment_provider(); - $result = $manual_enrolment_provider->enrol_learner( $user_id, $course_id ); - if ( ! $result ) { - error_log( sprintf( 'PMPro Courses (Sensei): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + public static function repair_user_enrollments( $user_id ) { + $user_id = (int) $user_id; + if ( empty( $user_id ) ) { + return; + } + + // Courses tied to any level. + $all_level_courses = array_map( 'intval', self::get_courses_for_levels( self::get_all_level_ids() ) ); + + // Courses tied to the user's current levels. + $current_levels = wp_list_pluck( (array) pmpro_getMembershipLevelsForUser( $user_id ), 'ID' ); + $current_level_courses = array_map( 'intval', self::get_courses_for_levels( $current_levels ) ); + + // Unenroll from level-restricted courses the user's levels no longer grant. + foreach ( array_diff( $all_level_courses, $current_level_courses ) as $course_id ) { + if ( Sensei_Course::is_user_enrolled( $course_id, $user_id ) ) { + Sensei_Utils::sensei_remove_user_from_course( $course_id, $user_id ); + } + } + + // Enroll in courses for the user's current levels. + foreach ( $current_level_courses as $course_id ) { + if ( ! Sensei_Course::is_user_enrolled( $course_id, $user_id ) ) { + $manual_enrolment_provider = Sensei_Course_Enrolment_Manager::instance()->get_manual_enrolment_provider(); + $manual_enrolment_provider->enrol_learner( $user_id, $course_id ); } } } + /** + * Get all membership level IDs. + * + * @return array + */ + private static function get_all_level_ids() { + return array_map( 'intval', wp_list_pluck( (array) pmpro_getAllLevels( true ), 'id' ) ); + } + /** * Get courses associated with a level. */ @@ -292,39 +323,8 @@ public static function get_courses_for_levels( $level_ids ) { * any associated private courses. */ public static function pmpro_after_all_membership_level_changes( $pmpro_old_user_levels ) { - foreach ( $pmpro_old_user_levels as $user_id => $old_levels ) { - // Get current courses. - $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); - if ( ! empty( $current_levels ) ) { - $current_levels = wp_list_pluck( $current_levels, 'ID' ); - } else { - $current_levels = array(); - } - $current_courses = self::get_courses_for_levels( $current_levels ); - - // Get old courses. - $old_levels = wp_list_pluck( $old_levels, 'ID' ); - $old_courses = self::get_courses_for_levels( $old_levels ); - - // Unenroll the user in any courses they used to have, but lost. - $courses_to_unenroll = array_diff( $old_courses, $current_courses ); - foreach ( $courses_to_unenroll as $course_id ) { - $is_user_taking_course = Sensei_Course::is_user_enrolled( $course_id, $user_id ); - - if ( $is_user_taking_course ) { - Sensei_Utils::sensei_remove_user_from_course( $course_id, $user_id ); - } - } - - // Enroll the user in any courses for their current levels. - $courses_to_enroll = array_diff( $current_courses, $old_courses ); - foreach ( $courses_to_enroll as $course_id ) { - $is_user_taking_course = Sensei_Course::is_user_enrolled( $course_id, $user_id ); - if ( ! $is_user_taking_course ) { - $manual_enrolment_provider = Sensei_Course_Enrolment_Manager::instance()->get_manual_enrolment_provider(); - $manual_enrolment_provider->enrol_learner( $user_id, $course_id ); - } - } + foreach ( array_keys( $pmpro_old_user_levels ) as $user_id ) { + self::repair_user_enrollments( $user_id ); } } diff --git a/includes/modules/tutorlms.php b/includes/modules/tutorlms.php index bcd6769..d369232 100644 --- a/includes/modules/tutorlms.php +++ b/includes/modules/tutorlms.php @@ -36,9 +36,9 @@ public function init_active() { add_action( 'pmpro_after_all_membership_level_changes', array( 'PMPro_Courses_TutorLMS', 'pmpro_after_all_membership_level_changes' ) ); - // Retroactive batch enrollment when a course is published with level associations. - add_action( 'save_post', array( 'PMPro_Courses_TutorLMS', 'on_course_save' ), 20 ); - add_action( 'pmpro_courses_tutorlms_retroactive_enroll_user', array( 'PMPro_Courses_TutorLMS', 'retroactive_enroll_user' ), 10, 2 ); + // Background repair of course enrollments when a course's level restrictions change. + add_filter( 'pmpro_courses_enrollment_course_post_types', array( 'PMPro_Courses_TutorLMS', 'enrollment_course_post_types' ) ); + add_action( PMPro_Courses_Batch_Enrollment::AS_HOOK_USER, array( 'PMPro_Courses_TutorLMS', 'repair_user_enrollments' ) ); } /** @@ -231,31 +231,62 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } /** - * Trigger retroactive enrollment when a Tutor LMS course is saved as published. + * Register the Tutor LMS course post type for background enrollment repair. * - * Runs at save_post priority 20 so PMPro has already persisted level associations. - * - * @param int $post_id The saved post ID. + * @param array $post_types Post type slugs. + * @return array */ - public static function on_course_save( $post_id ) { - PMPro_Courses_Batch_Enrollment::maybe_schedule_for_course( $post_id, 'courses', 'tutorlms' ); + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'courses'; + return $post_types; } /** - * Enroll a single user in a Tutor LMS course during retroactive batch processing. + * Reconcile a user's Tutor LMS course enrollments with their current membership levels. + * + * Idempotent: enrolls the user in courses for their current levels they are not yet + * in, and unenrolls them from level-restricted courses their levels no longer grant. + * Courses not tied to any level are never touched. * - * @param int $user_id User to enroll. - * @param int $course_id Tutor LMS course post ID. + * @param int $user_id User ID. */ - public static function retroactive_enroll_user( $user_id, $course_id ) { - if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) { - $result = tutor_utils()->do_enroll( $user_id, 0, $course_id ); - if ( ! $result ) { - error_log( sprintf( 'PMPro Courses (TutorLMS): Failed to enroll user %d in course %d.', $user_id, $course_id ) ); + public static function repair_user_enrollments( $user_id ) { + $user_id = (int) $user_id; + if ( empty( $user_id ) ) { + return; + } + + // Courses tied to any level. + $all_level_courses = array_map( 'intval', self::get_courses_for_levels( self::get_all_level_ids() ) ); + + // Courses tied to the user's current levels. + $current_levels = wp_list_pluck( (array) pmpro_getMembershipLevelsForUser( $user_id ), 'ID' ); + $current_level_courses = array_map( 'intval', self::get_courses_for_levels( $current_levels ) ); + + // Unenroll from level-restricted courses the user's levels no longer grant. + foreach ( array_diff( $all_level_courses, $current_level_courses ) as $course_id ) { + if ( tutor_utils()->is_enrolled( $course_id, $user_id ) ) { + tutor_utils()->cancel_course_enrol( $course_id, $user_id ); + } + } + + // Enroll in courses for the user's current levels. + foreach ( $current_level_courses as $course_id ) { + if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) { + tutor_utils()->do_enroll( $course_id, 0, $user_id ); } } } + /** + * Get all membership level IDs. + * + * @return array + */ + private static function get_all_level_ids() { + return array_map( 'intval', wp_list_pluck( (array) pmpro_getAllLevels( true ), 'id' ) ); + } + /** * Get courses associated with a level. */ @@ -295,38 +326,9 @@ public static function get_courses_for_levels( $level_ids ) { * any associated private courses. */ public static function pmpro_after_all_membership_level_changes( $pmpro_old_user_levels ) { - foreach ( $pmpro_old_user_levels as $user_id => $old_levels ) { - // Get current courses. - $current_levels = pmpro_getMembershipLevelsForUser( $user_id ); - if ( ! empty( $current_levels ) ) { - $current_levels = wp_list_pluck( $current_levels, 'ID' ); - } else { - $current_levels = array(); - } - $current_courses = PMPro_Courses_TutorLMS::get_courses_for_levels( $current_levels ); - - // Get old courses. - $old_levels = wp_list_pluck( $old_levels, 'ID' ); - $old_courses = PMPro_Courses_TutorLMS::get_courses_for_levels( $old_levels ); - - // Unenroll the user in any courses they used to have, but lost. - $courses_to_unenroll = array_diff( $old_courses, $current_courses ); - foreach( $courses_to_unenroll as $course_id ) { - if ( tutor_utils()->is_enrolled( $course_id, $user_id ) ) { - // True param here at the end tells it to remove. - tutor_utils()->cancel_course_enrol( $course_id, $user_id ); - } - } - - // Enroll the user in any courses for their current levels. - $courses_to_enroll = array_diff( $current_courses, $old_courses ); - foreach( $courses_to_enroll as $course_id ) { - if ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) { - tutor_utils()->do_enroll( $user_id, 0, $course_id ); - } - } - + foreach ( array_keys( $pmpro_old_user_levels ) as $user_id ) { + self::repair_user_enrollments( $user_id ); } - } + } } diff --git a/readme.txt b/readme.txt index c08725a..fba9595 100644 --- a/readme.txt +++ b/readme.txt @@ -135,6 +135,10 @@ Please visit [our support site at https://www.paidmembershipspro.com](http://www 4. A course page on the site frontend showing overview content, a registration box with required levels, and a list of lessons. == Changelog == += TBD = +* ENHANCEMENT: When a course's membership level restrictions change, existing members are now enrolled (and members who no longer qualify unenrolled) in the background via Action Scheduler. Requires Paid Memberships Pro 3.6+. (@andrewlimaza) +* BUG FIX: Fixed the argument order passed to Tutor LMS `do_enroll()`, which prevented members from being enrolled in Tutor LMS courses on level change. (@andrewlimaza) + = 2.0.1 - 2026-03-20 = * BUG FIX: Fixed an issue where course sections were not correctly working while using the Classic Editor. (@andrewlimaza)