Skip to content

Add date-based drip release for lessons - #142

Open
kimcoleman wants to merge 5 commits into
strangerstudios:lesson-dripfrom
kimcoleman:lesson-drip
Open

Add date-based drip release for lessons#142
kimcoleman wants to merge 5 commits into
strangerstudios:lesson-dripfrom
kimcoleman:lesson-drip

Conversation

@kimcoleman

Copy link
Copy Markdown
Member

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.

Status: verified with wp-cli unit and render tests (timezone/DST conversion, the access gate, save round-trips, outline markup across viewer states). It has not yet been exercised in a browser — the admin toggle and the redirect in particular deserve a click-through before merge.

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 Date datetime-local field

The date is entered in the site timezone (wp_timezone()) and stored as UTC. Free lessons hide the drip rows entirely.

Data model

Meta key Value
pmpro_courses_drip_method none | date
pmpro_courses_drip_date_gmt Unix timestamp, UTC

No 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.

Accesspmpro_has_membership_access_filter_pmpro_lesson at 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

  1. Lesson-level, not section-level. An earlier draft put scheduling on pmpro_course_sections course 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.

  2. 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.

  3. 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.

  4. 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).

  5. 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 than disabled — 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

  • Progress AJAX hardening. 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.
  • Enrollment-relative drip ("X days after signup") — needs a real course-enrollment record first; membership start is ambiguous across level changes and multi-level courses.
  • Drip notifications/emails.
  • No change to the course progress denominator. Locked published lessons still count, so a member reaches 100% only once the final lesson releases — preferable to hitting 100% and dropping backward when the next lesson opens.

Files

  • includes/drip.php (new) — helpers, no hooks
  • js/lesson-settings.js (new) — meta box toggle. Deliberately not added to admin.js, which calls pmpro_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.php

Docblocks use @since TBD.

Test plan

  • Set a future date on a lesson — outline shows lock + date, no link; direct URL redirects to the course
  • Same lesson as a logged-out visitor — lock, no date, no link
  • Let the date pass — lesson opens with no publish action or cron
  • Toggle Drip Method to None and save — the date is retained; toggle back and the schedule resumes
  • Check Free Lesson on a dripped lesson — drip rows hide, lesson stays open, date preserved on save
  • Confirm an editor can still open an unreleased lesson
  • Verify on a site with a named timezone (not a UTC offset), including a DST boundary date
  • Confirm lessons with no drip settings are completely unchanged

🤖 Generated with Claude Code

@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: #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 — checked paid-memberships-pro/includes/content.php for any rest_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. a rest_prepare_pmpro_lesson filter) should be added alongside this feature since it's going public.

  • Major includes/lessons.php:258-281pmpro_courses_redirect_unreleased_lesson() redirects unconditionally to get_permalink( $course_id ), bypassing the existing pmpro_courses_lesson_redirect_to filter 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 in apply_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 calls toggle_lesson_progress() (confirmed at progress.php:13-44) with only a login check — no pmpro_courses_is_lesson_released() gate. Any logged-in user can POST an arbitrary lid and 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 — The white-space: nowrap rule 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:51 vs includes/lessons.php:220is_lesson_released() checks '1' === get_post_meta(...) (strict) while the existing bypass filter at lessons.php:220 uses == '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 int 1 doesn'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-disabled approach 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 TBD placeholders are correct per convention.

Questions

  1. 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?

kimcoleman and others added 2 commits August 19, 2026 09:28
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>
@kimcoleman

Copy link
Copy Markdown
Member Author

Added commits to resolve:

  • Major includes/lessons.php:258-281 — pmpro_courses_redirect_unreleased_lesson() redirects unconditionally to get_permalink( $course_id ), bypassing the existing pmpro_courses_lesson_redirect_to filter 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).

  • Minor css/frontend.css:182-184 — The white-space: nowrap rule 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 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:51 vs includes/lessons.php:220 — is_lesson_released() checks '1' === get_post_meta(...) (strict) while the existing bypass filter at lessons.php:220 uses == '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 int 1 doesn't have "free" and "released" disagree.

We will address these in a separate PR:

  • 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 — checked paid-memberships-pro/includes/content.php for any rest_prepare_* hook, found none) gates the WP REST endpoint. GET /wp-json/wp/v2/pmpro_lesson/ returns an unreleased lesson's content regardless of drip date.

  • 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 calls toggle_lesson_progress() (confirmed at progress.php:13-44) with only a login check — no pmpro_courses_is_lesson_released() gate.

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>
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.

2 participants