Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions includes/batch-enrollment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php
/**
* Background repair of LMS course enrollments.
*
* When the level restrictions for a course change, we queue one Action Scheduler
* task per affected member. Each task asks the active LMS module to reconcile
* that member's enrollments against the current level -> 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 );
135 changes: 84 additions & 51 deletions includes/modules/learndash.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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 );
}
}
}
Loading