Skip to content
Open
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
282 changes: 282 additions & 0 deletions includes/batch-enrollment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
<?php
/**
* Batch enrollment for retroactive course enrollment.
*
* When a course is published with PMPro level associations, this queues
* background tasks via Action Scheduler to enroll all existing active
* members into the course. Only newly added level associations trigger
* enrollment; already-processed levels are tracked per-course.
*
* This is NOT used by the default module (no enrollment concept there).
*/

defined( 'ABSPATH' ) || exit;

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.
*/
const AS_HOOK = 'pmpro_courses_retroactive_enroll_batch';

/**
* Post meta key used to track which level IDs have already had
* retroactive enrollment queued for a given course.
*/
const PROCESSED_LEVELS_META = '_pmpro_courses_batch_enrollment_levels';

/**
* Register the Action Scheduler callback.
*/
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' ) );
}

/**
* 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').
*/
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 ) ) {
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 ) );
}

/**
* Schedule the first batch task for a course.
*
* @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' ) ) {
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.
);
}

/**
* 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
);
}
}

/**
* 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'
AND p.post_modified >= %s",
$post_type,
gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) )
)
);
}

/**
* 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 ) );
}
}

if ( class_exists( 'PMPro_Action_Scheduler' ) && function_exists( 'as_enqueue_async_action' ) ) {
PMPro_Courses_Batch_Enrollment::init();
}
2 changes: 1 addition & 1 deletion includes/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
2 changes: 1 addition & 1 deletion includes/courses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Expand Down
6 changes: 3 additions & 3 deletions includes/lessons.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() ) {
Expand All @@ -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) {

Expand Down
2 changes: 1 addition & 1 deletion includes/modules/default.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {

Expand Down
32 changes: 31 additions & 1 deletion 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' ) );

// 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 );
}

/**
Expand Down Expand Up @@ -207,6 +211,32 @@ 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 ) ) {
$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 ) );
}
}
}

/**
* Get courses associated with a level.
*/
Expand Down
Loading