Add date-based drip release for lessons - #142
Conversation
flintfromthebasement
left a comment
There was a problem hiding this comment.
PR: #142 — Add date-based drip release for lessons
kimcoleman → lesson-drip | 7 files, +356 -24
#142
Summary
Needs fixes before merge, not because the core mechanism is wrong but because the drip gate doesn't actually cover every path the PR description claims it does. The single-source-of-truth design (pmpro_courses_is_lesson_released()), the priority-20 access filter that can only revoke and never grant, and the save-routine's date preservation are all solid. Two real access-path gaps and one AJAX gap need attention before this ships publicly.
Issues
-
Major
includes/post-types/lessons.php:53-64— Lessons are registered'public' => true, 'show_in_rest' => true, and nothing in this PR (or in PMPro core — checkedpaid-memberships-pro/includes/content.phpfor anyrest_prepare_*hook, found none) gates the WP REST endpoint.GET /wp-json/wp/v2/pmpro_lesson/<id>returns an unreleased lesson's content regardless of drip date. This isn't new — no membership-restricted lesson content is REST-gated today, drip or not — but the PR description's claim that the access filter "protects REST and any other content path, independent of the redirect" is incorrect and should be corrected, or REST enforcement (e.g. arest_prepare_pmpro_lessonfilter) should be added alongside this feature since it's going public. -
Major
includes/lessons.php:258-281—pmpro_courses_redirect_unreleased_lesson()redirects unconditionally toget_permalink( $course_id ), bypassing the existingpmpro_courses_lesson_redirect_tofilter that the plugin's other lesson-denial redirect already applies (includes/courses.php:300, confirmed present in the current release,2.1.3, and also honored by the LearnDash/Sensei/Tutor modules). Sites that customize that filter to send denied lesson views to a login or upsell page will get inconsistent behavior: normal no-access lessons honor the customization, unreleased ones don't. Wrap the target inapply_filters( 'pmpro_courses_lesson_redirect_to', get_permalink( $course_id ) )for consistency. -
Major
includes/progress.php:273-309(pre-existing, but drip's stated guarantee depends on it) —pmpro_courses_toggle_lesson_progress_ajax()has no nonce or capability check and callstoggle_lesson_progress()(confirmed atprogress.php:13-44) with only a login check — nopmpro_courses_is_lesson_released()gate. Any logged-in user can POST an arbitrarylidand mark an unreleased lesson complete, which directly contradicts this PR's stated design goal that "a member reaches 100% only once the final lesson releases." The PR description defers general AJAX hardening to a follow-up, which is fine for the missing nonce/capability check, but the release-gate check specifically should probably land with this feature since drip is what makes the gap exploitable in a new way. -
Minor
css/frontend.css:182-184— Thewhite-space: nowraprule for lesson status only targets.pmpro_courses-list-item-link .pmpro_courses-lesson-status. The new "Locked" status lives inside.pmpro_courses-list-item-row(the<span>wrapper used when the lesson can't be linked), so it's missing that rule and can wrap on narrow viewports where "Complete"/"Incomplete" can't. Extend the selector to cover both wrappers. -
Minor
includes/drip.php:51vsincludes/lessons.php:220—is_lesson_released()checks'1' === get_post_meta(...)(strict) while the existing bypass filter atlessons.php:220uses== '1'(loose). Not reachable through the admin UI today (it only ever saves string'1'or''), but worth normalizing so a future integration that stores an int1doesn't have "free" and "released" disagree.
Looks Good
pmpro_courses_is_lesson_released()is the one function every consumer (access filter, redirect, outline render) routes through — free-lesson, method, and date checks all live in one place.- Date persists correctly across method toggles (
post-types/lessons.php), and the CSS-hide-instead-of-disabledapproach for the admin rows avoids the classic footgun of a disabled input not submitting. - Editor bypass uses
user_can( $user_id, 'edit_post', $lesson_id )— per-lesson, not a blanket capability. @since TBDplaceholders are correct per convention.
Questions
- Given the AJAX gap directly undermines this PR's stated 100%-completion guarantee, should the minimal
is_lesson_released()check land with this PR rather than the broader nonce/capability follow-up?
The drip redirect sent visitors to the parent course unconditionally, while every other lesson-denial redirect in the plugin (includes/courses.php and the LearnDash, Sensei, and Tutor modules) passes its target through pmpro_courses_lesson_redirect_to first. Sites filtering that hook to a login or upsell page saw it respected for no-access lessons but ignored for undripped ones. Only the member-with-course-access case was affected. When a visitor lacks course access, pmpro_courses_template_redirect runs first (loaded earlier at the same priority) and already applies the filter. Matches the pattern used at the other five call sites: apply the filter, then redirect only on a truthy value, so filtering to null falls through to PMPro's no access message. The no-parent case now goes through the filter as well, so a customized target still works on a lesson with no parent course. Switches wp_safe_redirect to wp_redirect to match those call sites and to allow off-site targets. The value comes from site PHP, not request input, so wp_safe_redirect only served to silently rewrite an external upsell URL to the homepage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lesson status positioning only targeted .pmpro_courses-list-item-link. The "Locked" status renders inside .pmpro_courses-list-item-row, the span wrapper used when a lesson cannot be linked, so it missed the rule. Both wrappers are now covered. The status label is screen reader only and both wrappers already get display:flex from .pmpro_courses-list-item > *, so there was no visible wrapping or alignment difference. This keeps the two wrappers from diverging if the label is ever shown or a longer status is added. Adds pmpro_courses_lesson_is_free() and uses it for all three runtime reads of pmpro_courses_bypass_restriction, which had drifted into three comparison styles: strict in pmpro_courses_is_lesson_released(), loose in pmpro_lessons_bypass_check(), and a bare truthy read in the lesson list. A stored int 1 or bool true would have been "free" but not "released", locking a lesson that is meant to be public. Not reachable through the admin UI, which only ever saves '1' or ''. The helper casts before comparing strictly, so it accepts '1', 1 and true and matches the previous loose check on every other value. No lesson's access decision changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Added commits to resolve:
We will address these in a separate PR:
|
template_redirect only runs for front end page views, so the REST API, feeds and archives had no release policy of their own. They were not leaking full bodies: WordPress sets up the post global before applying the_content (class-wp-rest-posts-controller.php), so PMPro's content filter runs and the drip access filter already denied access. The gap was the excerpt. With "Show Excerpts to Non-Members" enabled, pmpro_membership_content_filter() falls back to an excerpt of the body, so an unreleased lesson exposed its opening text to anyone, members included. A release date is stronger than a membership restriction, so nothing should be shown before it passes. Hooks PMPro's pmpro_membership_content_filter short circuit rather than rest_prepare_pmpro_lesson, so every the_content context is covered instead of the REST API alone. The lesson post type does not support excerpts, so content.rendered is the only body field to protect. Verified against a scheduled lesson with excerpts forced on: the body and its excerpt are withheld from an anonymous visitor and from an active member of a level that grants access, while released restricted lessons still return PMPro's no access message and free lessons are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Adds an optional per-lesson drip release to the Default Courses module. A lesson can be set to become available at a specific date and time; until then it behaves exactly like a lesson the visitor has no access to.
Built for the Launch Challenge Cohort, then intended for public release.
Scope is the Default Course + Lesson module only. LearnDash, LifterLMS, Sensei, and Tutor LMS modules are untouched.
Admin
A new Drip Method row in the existing Lesson Settings meta box:
None, the lesson is available immediately(default)Access is granted on a specific date— reveals a Release Datedatetime-localfieldThe date is entered in the site timezone (
wp_timezone()) and stored as UTC. Free lessons hide the drip rows entirely.Data model
pmpro_courses_drip_methodnone|datepmpro_courses_drip_date_gmtNo new tables and no migration. Absent meta reads as
none, so every existing lesson is unaffected. The method key is a discriminator so future modes (e.g. enrollment-relative drip) add a case rather than a migration.Behavior
Course outline — an unreleased lesson stays visible in the outline but is not hyperlinked, shows a lock icon in the status rail, and shows
Available {date}under the title only for visitors who could otherwise open it. Everyone else gets a generic lock with no date.Direct URL — an unreleased lesson redirects to its parent course (
template_redirect). No lesson content, excerpt, or completion control is rendered.Access —
pmpro_has_membership_access_filter_pmpro_lessonat priority 20. It can only ever remove access, never grant it, so it cannot widen an existing restriction. This is what protects REST and any other content path, independent of the redirect.No cron. Availability is a timestamp comparison at request time, so a lesson opens the moment its date passes.
Editors bypass the release clock only — never membership restrictions — so unreleased lessons stay previewable.
Design decisions
Lesson-level, not section-level. An earlier draft put scheduling on
pmpro_course_sectionscourse meta. Lesson post meta means independent dates per lesson, no outline-data migration, no fragile preservation during section reorder, and a lesson keeps its rule when moved between sections.Locked means locked — no bespoke screen. An unavailable lesson reuses the existing no-access behavior rather than introducing a second pattern. This also removed the need to distinguish "denied because unreleased" from "denied because not a member," which deleted two helper functions along with the screen.
Free supersedes drip. A free lesson is public, so a drip date never applies. Enforced in
pmpro_courses_is_lesson_released()— the single function every consumer routes through — rather than at each call site.One lock concept. The lock icon shows for any lesson the visitor cannot open, not just dripped ones. Previously a non-member saw an empty status rail; now the rail is always exactly one icon (lock / circle / check-circle).
The date survives switching the method to
none, so toggling drip off and back on doesn't lose it. This is also why the admin rows hide with CSS rather thandisabled— a disabled input doesn't submit, which would wipe the value on the next save.Vocabulary is deliberate: drip for the admin configuration, release/released for the runtime outcome.
Not included
pmpro_courses_toggle_lesson_progress_ajax()has no nonce, capability, or access check and accepts GET. That is pre-existing and unrelated to drip, so it belongs in its own PR.Files
includes/drip.php(new) — helpers, no hooksjs/lesson-settings.js(new) — meta box toggle. Deliberately not added toadmin.js, which callspmpro_courses_select2()unconditionally while select2 only enqueues for the course editor; loading it on the lesson screen throws.includes/lessons.php,includes/common.php,includes/post-types/lessons.php,css/frontend.css,pmpro-courses.phpDocblocks use
@since TBD.Test plan
Noneand save — the date is retained; toggle back and the schedule resumes🤖 Generated with Claude Code