Skip to content

Batch processing for retroactively enrolling members to courses. - #108

Draft
andrewlimaza wants to merge 5 commits into
strangerstudios:devfrom
andrewlimaza:retroactive-enrollment-for-modules
Draft

Batch processing for retroactively enrolling members to courses.#108
andrewlimaza wants to merge 5 commits into
strangerstudios:devfrom
andrewlimaza:retroactive-enrollment-for-modules

Conversation

@andrewlimaza

@andrewlimaza andrewlimaza commented Apr 2, 2026

Copy link
Copy Markdown
Contributor
  • ENHANCEMENT: When a course's membership level restrictions change, existing members are enrolled (and members who no longer qualify are unenrolled) in the background via Action Scheduler. Requires PMPro 3.6+.

Resolves #72.

Approach

Reworked per @dparker1005's review to mirror PMPro core's LifterLMS streamline pattern (includes/compatibility/lifterlms.php):

  • Trigger: pmpro_after_updating_post_level_restrictions (PMPro 3.6). Fires from the Require Membership meta box, the REST API, and programmatic pmpro_update_post_level_restrictions() calls. No more save_post hook or daily cron.
  • Fan-out: the hook queues one pmpro_courses_repair_all_enrollments_callback task per course, which runs PMPro_Action_Scheduler::halt() → one pmpro_courses_repair_user_enrollments task per member → resume(). Keyset pagination on user_id, no shared OFFSET cursor.
  • Idempotent: the _pmpro_courses_batch_enrollment_levels post meta is gone. Each LMS module has a single repair_user_enrollments( $user_id ) that diffs the user's current-level courses against all level-restricted courses and enrolls/unenrolls the difference, checking is_enrolled first. pmpro_after_all_membership_level_changes now calls the same method, so both paths share one code path.
  • Modules register their course post type via the pmpro_courses_enrollment_course_post_types filter and hook PMPro_Courses_Batch_Enrollment::AS_HOOK_USER.

Note: the fan-out queues everyone who has ever held a level, not just members of the course's current levels. The hook fires after the change, so a level that was just removed from the course is no longer visible — its members would otherwise never be unenrolled. Core makes the same trade.

Behavior change to be aware of: the repair unenrolls a user from any level-restricted course their current levels don't grant (previously only courses tied to levels they lost). A student manually enrolled in a restricted course without the level will be unenrolled on the next repair. This is what makes it self-healing and matches core's LifterLMS behavior.

How to test the changes in this Pull Request:

  1. Ensure PMPro 3.6+ is active and you have members on at least one level.
  2. Activate an LMS module with enrollment (LearnDash, LifterLMS, Sensei, Tutor) and create a few courses without any level restrictions. Confirm no members are enrolled.
  3. Pull this PR.
  4. Edit a course and set Require Membership to one or more levels. Save.
  5. Check Tools → Scheduled Actions: a pmpro_courses_repair_all_enrollments_callback task should appear, followed by one pmpro_courses_repair_user_enrollments task per member (group pmpro_courses_enrollment). Run them (or wait for cron).
  6. Confirm members of the selected level(s) are now enrolled in the course; members of other levels are not.
  7. Remove a level from the course's Require Membership and save. After the tasks run, confirm members of that level were unenrolled.
  8. Change a member's level (admin Edit Member, or a checkout) and confirm enrollments update immediately as before.

Tested with LearnDash; other modules follow the same structure but should be verified before release.

LearnDash: repair_user_enrollments() also reconciles Group enrollment (from #111), and the groups post type triggers a repair when its level restrictions change.

* FEATURE: Added support for batch enrollment for course integrations that use "enrollment".

@flintfromthebasement flintfromthebasement left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR: #108 — Batch processing for retroactively enrolling members to courses.
andrewlimaza → dev | 15 files, +450 -46 lines
#108

Summary
Solid foundation for retroactive batch enrollment via Action Scheduler — the class structure, pagination via offset chaining, and per-course processed-levels tracking are all sound. One correctness issue undermines the stated goal for old courses; a few minor things to clean up before merge. Not blocking, but the major issue should be fixed.


Issues

  • Major includes/batch-enrollment.php:206-218 (get_published_course_ids_for_post_type) — The daily cron is supposed to retroactively enroll members in courses that existed before this plugin version, but the query filters AND p.post_modified >= %s (last 24 hours). This means any course that hasn't been touched in more than a day is silently skipped every single daily run, forever. The PROCESSED_LEVELS_META already prevents re-processing enrolled levels — the time filter is redundant and actively breaks the retroactive goal. Remove the post_modified filter entirely, or query for courses that don't yet have _pmpro_courses_batch_enrollment_levels set and let the metadata be the deduplication gate.

  • Minor includes/modules/learndash.php:228 (retroactive_enroll_user) — ld_update_course_access() does not have a documented return value and returns void in some LearnDash versions. if ( ! $result ) will always be truthy when it returns null/false, logging a spurious error on every successful enrollment. Either drop the return-value check or verify the current LD version actually returns a boolean here.

  • Minor includes/batch-enrollment.php:96-109 (schedule) vs process_batch:163-172 — The first batch task goes through PMPro_Action_Scheduler::instance()->maybe_add_task() (presumably deduplication-aware), but chained batches call as_enqueue_async_action() directly. This inconsistency is probably intentional (chained offsets shouldn't deduplicate), but a comment explaining why the two paths differ would prevent someone from "fixing" it later.

  • Minor includes/batch-enrollment.php:278-280 (init guard) — The class_exists('PMPro_Action_Scheduler') check runs at include-time. Since batch-enrollment.php is require_once'd directly in pmpro-courses.php (before plugins_loaded fires), this guard could fail if PMPro loads after pmpro-courses depending on alphabetical plugin order. Wrapping the init() call in add_action('plugins_loaded', ...) with a late priority would make this robust.


Looks Good

get_active_members() — correct use of ORDER BY user_id before LIMIT/OFFSET ensures consistent pagination across batch runs. array_map('intval', $level_ids) + sort() before building placeholders prevents both injection and unstable query plans.

maybe_schedule_for_course() — the autosave/revision guards and the post_status === 'publish' check are all in the right place. Clean.


Questions

  1. The PR description notes this "won't re-run batches whenever a member is added to a level programmatically and bypasses any WordPress hooks." Is that known gap documented anywhere user-facing, or does it need a notice in the admin or release notes?

  2. tutorlms.php:retroactive_enroll_userdo_enroll( $user_id, 0, $course_id ) matches the pre-existing pattern at line 295, but TutorLMS's documented signature is do_enroll($course_id, $order_id, $user_id). Can you confirm this arg order is correct for the TutorLMS version being targeted? The pre-existing code may have a latent bug here that this PR is replicating.

@dparker1005

Copy link
Copy Markdown
Member

Hey Andrew — going to mark this draft for now while we rethink the approach. The architecture is solid as a starting point, but after looking at how PMPro core handles the same problem for the LifterLMS streamline mode (paid-memberships-pro/includes/compatibility/lifterlms.php, especially pmpro_lifter_repair_course_enrollments and pmpro_lifter_repair_all_course_enrollments_callback), I think we should mirror that pattern here rather than ship this as-is. Three reasons:

  1. Trigger. Core hooks pmpro_after_updating_post_level_restrictions (added in PMPro 3.6), which fires precisely when a post's level restrictions actually change — including from the standard Require Membership meta box save. save_post priority 20 fires on every post save (autosave guards help, but still noisy) and won't fire if restrictions are changed via the REST API or a programmatic call that goes through pmpro_update_post_level_restrictions() without a save_post. The dedicated hook is the right signal.

  2. Idempotency. Core's repair function diffs the user's actual enrolled courses against their current level → course map every time it runs, so it's self-healing — if a save_post event was missed, or post meta got cleared, or anything else drifted, the next run reconciles. This PR uses _pmpro_courses_batch_enrollment_levels post meta as a dedup gate, which means once a level is marked processed, retroactive enrollment for that level will never re-fire on that course — even if it should. Combined with the daily backstop having AND p.post_modified >= last 24 hours, courses that aren't touched daily can fall through both safety nets permanently.

  3. Concurrency / batching. Core queues one AS task per user via PMPro_Action_Scheduler::halt() / resume() while building up the queue, so there's no shared OFFSET cursor and no risk of pagination skip/repeat if users are added or removed mid-run. This PR's chained-offset approach works but is fragile under churn.

The shape I'd suggest mirroring:

  • Drop the save_post hook and the daily cron. Trigger off pmpro_after_updating_post_level_restrictions instead.
  • Replace process_batch (offset-paginated) with a fanout pattern: queue one pmpro_courses_repair_user_enrollment AS task per active member of any level tied to the affected course. Use PMPro_Action_Scheduler::halt()/resume() around the queue-up loop.
  • Drop the _pmpro_courses_batch_enrollment_levels post meta. Each per-user task is idempotent because it checks the LMS-specific is_enrolled API before calling enroll.
  • Optionally: consolidate the per-module retroactive_enroll_user methods into a single repair_user_enrollments($user_id) per module that handles both directions (the way pmpro_lifter_repair_course_enrollments does for LifterLMS). That would also let pmpro_after_all_membership_level_changes call the same repair function instead of duplicating its own enroll/unenroll logic.

One more thing while we're here: tutorlms.php line 252 calls tutor_utils()->do_enroll( $user_id, 0, $course_id ) but Tutor's documented signature is do_enroll( $course_id, $order_id, $user_id ). The pre-existing code at line 295 has the same arg order so it might be a latent bug elsewhere too — worth a follow-up issue regardless of how this PR ends up.

Going to convert this to Draft for now.

@dparker1005
dparker1005 marked this pull request as draft May 13, 2026 16:12
- 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 <noreply@anthropic.com>
@andrewlimaza
andrewlimaza marked this pull request as ready for review September 2, 2026 12:59
@andrewlimaza

Copy link
Copy Markdown
Contributor Author

Reworked in 91442b9 per the feedback above — now hooks pmpro_after_updating_post_level_restrictions, fans out one AS task per user with halt()/resume(), drops the post-meta gate, consolidates each module's enroll/unenroll into repair_user_enrollments(), and fixes the Tutor do_enroll() arg order. PR description updated with the new approach and test steps. Marking ready for review.

Resolve conflicts: fold LearnDash group enrollment (strangerstudios#111) into repair_user_enrollments(),
drop the Tutor do_enroll changelog line already shipped in 2.1.3 (strangerstudios#135).

Co-Authored-By: Claude Code <noreply@anthropic.com>
@andrewlimaza
andrewlimaza marked this pull request as draft September 2, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include ability to add a new course to existing members

3 participants