diff --git a/includes/batch-enrollment.php b/includes/batch-enrollment.php new file mode 100644 index 0000000..824a120 --- /dev/null +++ b/includes/batch-enrollment.php @@ -0,0 +1,173 @@ + 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). + */ + +defined( 'ABSPATH' ) || exit; + +class PMPro_Courses_Batch_Enrollment { + + /** + * Action Scheduler group name. + */ + const AS_GROUP = 'pmpro_courses_enrollment'; + + /** + * Action Scheduler hook that fans out one task per user. + */ + const AS_HOOK_QUEUE = 'pmpro_courses_repair_all_enrollments_callback'; + + /** + * Action Scheduler hook that repairs a single user's enrollments. + * LMS modules hook their own repair method onto this action. + */ + const AS_HOOK_USER = 'pmpro_courses_repair_user_enrollments'; + + /** + * Number of user IDs to fetch per query while building the queue. + */ + const QUEUE_CHUNK_SIZE = 250; + + /** + * Register hooks. Runs on plugins_loaded so PMPro core is guaranteed to be loaded. + */ + public static function init() { + if ( ! self::is_available() ) { + return; + } + + 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' ) ); + } + + /** + * Whether the PMPro Action Scheduler wrapper is available (PMPro 3.6+). + * + * @return bool + */ + public static function is_available() { + return class_exists( 'PMPro_Action_Scheduler' ) && function_exists( 'as_enqueue_async_action' ); + } + + /** + * Course post types for the active LMS modules. + * + * @return array Post type slugs. + */ + 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() ) ); + } + + /** + * When the level restrictions for a course change, queue a repair for its members. + * + * @param int $post_id The post whose level restrictions were updated. + */ + 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; + } + + // No module is listening, nothing to do. + if ( ! has_action( self::AS_HOOK_USER ) ) { + return; + } + + self::schedule_repair_for_course( $post_id ); + } + + /** + * Queue the fan-out task for a course. + * + * @param int $course_id Course post ID. + */ + public static function schedule_repair_for_course( $course_id ) { + if ( ! self::is_available() ) { + return; + } + + PMPro_Action_Scheduler::instance()->maybe_add_task( + self::AS_HOOK_QUEUE, + array( 'course_id' => (int) $course_id ), + self::AS_GROUP, + null, + true + ); + } + + /** + * 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 $course_id Course post ID. + */ + public static function repair_all_enrollments_callback( $course_id ) { + global $wpdb; + + // 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(); + } + + /** + * Queue a repair task for a single user. + * + * @param int $user_id User ID. + */ + public static function schedule_repair_for_user( $user_id ) { + if ( ! self::is_available() ) { + return; + } + + PMPro_Action_Scheduler::instance()->maybe_add_task( + self::AS_HOOK_USER, + array( 'user_id' => (int) $user_id ), + self::AS_GROUP + ); + } +} +// 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 26f9d28..d96cd00 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' ) ); + + // 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' ) ); } /** @@ -209,6 +213,83 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } } + /** + * Register the LearnDash course and group post types for background enrollment repair. + * + * @param array $post_types Post type slugs. + * @return array + */ + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'sfwd-courses'; + $post_types[] = 'groups'; + return $post_types; + } + + /** + * Reconcile a user's LearnDash course and group enrollments with their current membership levels. + * + * Idempotent: enrolls the user in courses/groups for their current levels they are not yet + * in, and unenrolls them from level-restricted courses/groups their levels no longer grant. + * Courses and groups not tied to any level are never touched. + * + * @param int $user_id User ID. + */ + public static function repair_user_enrollments( $user_id ) { + $user_id = (int) $user_id; + if ( empty( $user_id ) ) { + return; + } + + $all_levels = self::get_all_level_ids(); + $current_levels = wp_list_pluck( (array) pmpro_getMembershipLevelsForUser( $user_id ), 'ID' ); + + // Courses tied to any level, and to the user's current levels. + $all_level_courses = array_map( 'intval', self::get_courses_for_levels( $all_levels ) ); + $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 ); + } + } + + // Groups tied to any level, and to the user's current levels. + $all_level_groups = array_map( 'intval', self::get_groups_for_levels( $all_levels ) ); + $current_level_groups = array_map( 'intval', self::get_groups_for_levels( $current_levels ) ); + + // Unenroll from level-restricted groups the user's levels no longer grant. + foreach ( array_diff( $all_level_groups, $current_level_groups ) as $group_id ) { + if ( learndash_is_user_in_group( $user_id, $group_id ) ) { + ld_update_group_access( $user_id, $group_id, true ); + } + } + + // Enroll in groups for the user's current levels. + foreach ( $current_level_groups as $group_id ) { + if ( ! learndash_is_user_in_group( $user_id, $group_id ) ) { + ld_update_group_access( $user_id, $group_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. */ @@ -228,56 +309,8 @@ public static function get_groups_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 ); - } - } - - // Get current and old groups. - $current_groups = PMPro_Courses_LearnDash::get_groups_for_levels( $current_levels ); - $old_groups = PMPro_Courses_LearnDash::get_groups_for_levels( $old_levels ); - - // Unenroll the user from any groups they used to have, but lost. - $groups_to_unenroll = array_diff( $old_groups, $current_groups ); - foreach ( $groups_to_unenroll as $group_id ) { - if ( learndash_is_user_in_group( $user_id, $group_id ) ) { - ld_update_group_access( $user_id, $group_id, true ); - } - } - - // Enroll the user in any groups for their current levels. - $groups_to_enroll = array_diff( $current_groups, $old_groups ); - foreach ( $groups_to_enroll as $group_id ) { - if ( ! learndash_is_user_in_group( $user_id, $group_id ) ) { - ld_update_group_access( $user_id, $group_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 7c3070a..47fc042 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' ) ); + + // 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' ) ); } /** @@ -86,37 +90,94 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } } + /** + * Register the LifterLMS course post type for background enrollment repair. + * + * @param array $post_types Post type slugs. + * @return array + */ + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'course'; + return $post_types; + } + + /** + * 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 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. */ 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; } @@ -124,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 6796d7f..90cfbcd 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' ) ); + + // 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' ) ); } /** @@ -222,6 +226,64 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig } } + /** + * Register the Sensei LMS course post type for background enrollment repair. + * + * @param array $post_types Post type slugs. + * @return array + */ + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'course'; + return $post_types; + } + + /** + * 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 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. */ @@ -243,12 +305,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 ) ) ); @@ -261,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 ee8f21f..0dc689b 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' ) ); + + // 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' ) ); } /** @@ -224,37 +228,94 @@ public static function pmpro_membership_content_filter( $filtered_content, $orig return $filtered_content; // In case we don't get here. } + /** + * Register the Tutor LMS course post type for background enrollment repair. + * + * @param array $post_types Post type slugs. + * @return array + */ + public static function enrollment_course_post_types( $post_types ) { + $post_types[] = 'courses'; + return $post_types; + } + + /** + * 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 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. */ 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; } @@ -263,38 +324,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( $course_id, 0, $user_id ); - } - } - + foreach ( array_keys( $pmpro_old_user_levels ) as $user_id ) { + self::repair_user_enrollments( $user_id ); } - } + } } diff --git a/pmpro-courses.php b/pmpro-courses.php index 49cf845..26a91e0 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() { diff --git a/readme.txt b/readme.txt index ce8fd19..0b42699 100644 --- a/readme.txt +++ b/readme.txt @@ -135,6 +135,9 @@ 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+. #108 (@andrewlimaza) + = 2.1.3 - 2026-07-30 = * BUG FIX: Fixed members not being enrolled in Tutor LMS courses when their membership level changed. The arguments passed to Tutor's `do_enroll()` were transposed, so enrollment silently failed. #135 (@dparker1005)