feat(access-control): auto-signup on renewal only for already subscribed lists#4621
feat(access-control): auto-signup on renewal only for already subscribed lists#4621
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refines Premium Newsletters’ auto-signup behavior during subscription renewals by snapshotting the reader’s current newsletter subscriptions at renewal start and using that snapshot to limit which restricted lists can be auto-added during the subsequent access check.
Changes:
- Cache the reader’s subscribed newsletter lists to user meta when a renewal attempt begins.
- Gate auto-signup during access checks so only lists present in that cached snapshot can be re-added, then clear the snapshot meta.
- Add a small in-request cache for premium newsletter gates (
get_gates()).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
adekbadek
left a comment
There was a problem hiding this comment.
Nice, tightly scoped change. The renewal-snapshot approach fits the stated problem cleanly and the new tests cover the happy paths well. I want to flag one correctness concern that I think undermines the fix on retries (see inline on the delete_user_meta call) plus a design question about unrelated events inheriting the snapshot. Everything else is suggestion/nit.
One out-of-scope observation while I was here: process_access_check_queue() doesn't wrap the per-user check_access() in a try/catch, so a single bad user (e.g. a deleted list post referenced from the restriction rules) throws out of the whole pass and aborts the batch. Pre-existing, but combined with the blocker below it makes the retry contract fragile — worth a follow-up to catch per user and log.
| } | ||
| } | ||
|
|
||
| delete_user_meta( $user_id, self::SUBSCRIBED_LISTS_META_KEY ); |
There was a problem hiding this comment.
Blocker: this delete_user_meta() runs unconditionally before add_and_remove_lists() on line 250. The class docblock on process_access_check_queue() (:302-304) explicitly relies on queue-persists-on-error so failed entries are retried on the next cron tick. But if add_and_remove_lists() fails (AC 5xx, network timeout, auth error, partial response), the snapshot is already gone — the retry then sees "no meta = add all" and re-adds the list the reader unsubscribed from, which is exactly the regression this PR is trying to prevent.
Suggest moving the delete after a successful add_and_remove_lists(), e.g. guarded by a success flag or a try { ... } finally {} that only deletes on the success branch. Worth a companion test that injects a throwing provider and asserts the meta still exists afterwards.
There was a problem hiding this comment.
Good catch—added the try / catch block in 014ffce.
| $auto_signup = (bool) get_option( 'newspack_premium_newsletters_auto_signup', 1 ); | ||
| $lists_to_add = []; | ||
| $lists_to_remove = []; | ||
| $subscribed_lists = get_user_meta( $user_id, self::SUBSCRIBED_LISTS_META_KEY, true ); |
There was a problem hiding this comment.
Design question: between set_subscribed_lists() firing on renewal and the next cron run, any other event that enqueues the same user (reader_verified, product_subscription_changed, domain whitelist verification, etc.) will also read this snapshot and silently suppress auto-signup of lists the user dropped — changing the semantics of those unrelated flows. E.g. test_reader_verified_grants_list_access_for_whitelisted_domain would behave differently for any user who had a recent renewal.
Worth considering tagging queue entries with the triggering event, or adding an expiry to the meta, so the snapshot only governs the renewal-triggered check. Not a blocker, but the coupling feels load-bearing enough to raise.
There was a problem hiding this comment.
Good call — went with the tagging approach in c615c89. Each queue entry is now [ 'user_id', 'source' ], with SOURCE_RENEWAL reserved for entries enqueued by set_subscribed_lists() itself (made it self-enqueue rather than rely on product_subscription_changed firing alongside). Per-event wrappers handle_product_subscription_changed, handle_donation_subscription_changed, handle_reader_verified tag their respective sources.
check_access() only reads/clears the snapshot when source === SOURCE_RENEWAL. So reader_verified (or any other event) hitting a user with a stale snapshot now does normal auto-signup and leaves the snapshot intact for any subsequent renewal-source check.
Dedup logic in add_user_to_queue() prefers renewal: a renewal-source enqueue overrides any non-renewal entry for the same user, but a non-renewal source never downgrades an existing renewal entry. That keeps the renewal semantics intact when subscription_renewal_attempt and product_subscription_changed both fire in the same window.
Backward compat: queue entries already in flight at deploy are bare ints; a normalize_queue_entry() helper reads both shapes so the rollover is safe.
New test test_non_renewal_event_does_not_consult_snapshot plants a stale snapshot, fires handle_reader_verified, and asserts (a) the user gets auto-subscribed, and (b) the snapshot survives.
| if ( ! $auto_signup ) { | ||
| return; | ||
| } | ||
| $email = $user->user_email; |
There was a problem hiding this comment.
Nit: the stored payload is the full contact-list set from get_contact_lists(), not just the restricted lists. Behavior is still correct because check_access only compares against restricted lists, but the PR title and class docblock read as if this is a restricted-only snapshot. Either filter to restricted public IDs at snapshot time, or update the docblock/meta-key name to say "the contact's full ESP list membership at renewal time."
| $lists_to_remove[] = $list_id; | ||
| } elseif ( $auto_signup ) { | ||
| $lists_to_add[] = $list_id; | ||
| if ( ! is_array( $subscribed_lists ) || in_array( self::get_public_id( $list_id ), $subscribed_lists, true ) ) { |
There was a problem hiding this comment.
Minor perf: self::get_public_id( $list_id ) instantiates a Subscription_List per restricted list per user per cron run. Given the PR's secondary goal of reducing repeated queries, consider hoisting a $restricted_list_public_ids map once before the foreach.
Also worth noting: get_public_id() can return null, and in_array( null, $subscribed_lists, true ) silently drops the list from lists_to_add. A comment (or an explicit guard + log) would make that behavior intentional rather than incidental.
| $this->assertEmpty( $calls[0]['lists_to_remove'] ); | ||
| } | ||
|
|
||
| // ========================================================================= |
There was a problem hiding this comment.
Test gap suggestions:
- Failure retry: add a test that stubs a throwing/erroring
add_and_remove_lists(e.g. via a mock provider) and assertsSUBSCRIBED_LISTS_META_KEYis still present aftercheck_access— this is the test that would have caught the blocker I flagged onclass-premium-newsletters.php:248. auto_signup = 0path:set_subscribed_lists()early-returns when the option is off. A one-liner test that confirms nothing is written in that case prevents regressions if the guard is touched later.test_renewal_readds_user_who_remained_subscribedzeroes out$contact_listsmid-test to avoid the dedup filter. The inline comment explains why, but the scenario it simulates ("ESP drops the user between renewal and cron") is narrower than the name suggests — consider renaming or splitting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks, @adekbadek—addressed your feedback! |
|
Re: the out-of-scope observation about Each entry now runs in its own Covered by |
All Submissions:
Changes proposed in this Pull Request:
#4589 added functionality to auto-signup users to restricted lists when they meet access requirements for those lists. However, if a reader purchases a subscription that includes access to restricted lists, then subsequently unsubscribe from a restricted list outside of the site (such as by clicking on the "unsubscribe from this newsletter" link in the delivered email), we don't want to automatically re-add them to this list, which could result in frustration on the user's part.
This PR adds a check when a renewal order is created to cache the user's subscribed lists as user meta. When an access check for the user occurs, if that user meta exists, auto-signup will only occur for lists the user was already subscribed to when the most recent renewal began. The user meta cache is cleared after any access check is completed, so it doesn't persist beyond a single pending check.
This way, auto-signup will only occur after a new subscription is created, a subscription is switched to a different product within the same group/parent, or if the subscription is activated through means other than a renewal (such as an admin action, CLI script, or a subscription status change via manual user action in My Account).
Since the access check queue is only processed once per hour, there's a small chance a reader could unsubscribe from a restricted list after a renewal starts but before their scheduled access check occurs. In this case they'll be added back to the list when the queue is processed. But the chance that an intentional unsubscribe happens in this interim period is pretty low.
Also, caches the premium newsletter gates when processing the queue so we don't perform more post queries than we need to for a large queue of users.
How to test the changes in this Pull Request:
wp cron event run newspack_premium_newsletters_access_checkOther information: