Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fd0d561
feat: add Network ID metabox to WooCommerce products
miguelpeixe Mar 10, 2026
2991132
feat: add product Network ID data event and sync
miguelpeixe Mar 10, 2026
6b6f46f
feat: add network-aware access rule evaluation via product Network ID
miguelpeixe Mar 10, 2026
22c80b6
feat: add product-level network purchase restrictions for content gates
miguelpeixe Mar 10, 2026
a7da93c
fix: align variable assignment in Access class
miguelpeixe Mar 10, 2026
30e6e9f
fix: cast product ID to string for safe array key lookup
miguelpeixe Mar 10, 2026
b26a4f9
fix: break after first network subscription validation error
miguelpeixe Mar 10, 2026
c822cb8
fix: restrict network purchase checks to subscription products only
miguelpeixe Mar 10, 2026
e07c949
fix: disable autoload for network products option
miguelpeixe Mar 10, 2026
b84c44d
fix: allow multiple products to share the same Network ID
miguelpeixe Mar 10, 2026
cddca5e
test: add unit tests for Content Gate network access
miguelpeixe Mar 10, 2026
f6f9757
chore: standardize array formatting in unit tests for content gate ac…
miguelpeixe Mar 10, 2026
0ed8466
fix: restrict network ID metabox to subscription product types
miguelpeixe Mar 10, 2026
974e3bf
fix: process product updates for hub-originated events
miguelpeixe Mar 10, 2026
11457a6
fix: add WooCommerce active check to product backfiller
miguelpeixe Mar 10, 2026
0068b15
Update includes/hub/stores/event-log-items/class-product-updated.php
miguelpeixe Mar 10, 2026
c7e6228
fix: add WooCommerce active check to product_updated listener
miguelpeixe Mar 10, 2026
d73b938
fix: use save_post_product hook and guard against missing WooCommerce
miguelpeixe Mar 10, 2026
e1731b4
Merge branch 'feat/network-product-id' of https://github.com/Automatt…
miguelpeixe Mar 10, 2026
a34d529
fix: handle network ID for product variations
miguelpeixe Mar 11, 2026
561a1b6
fix: lint and revert unneeded subscription change
miguelpeixe Mar 11, 2026
85f663e
fix: remove unnecessary network ID check for variable subscriptions
miguelpeixe Mar 11, 2026
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
2 changes: 2 additions & 0 deletions includes/class-accepted-actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class Accepted_Actions {
'network_incoming_post_deleted' => 'Network_Incoming_Post_Deleted',
'newspack_network_distributor_migrate_incoming_posts' => 'Distributor_Migrate_Incoming_Posts',
'network_hub_name_updated' => 'Hub_Name_Updated',
'newspack_network_product_updated' => 'Product_Updated',
];

/**
Expand All @@ -71,5 +72,6 @@ class Accepted_Actions {
'network_incoming_post_deleted',
'newspack_network_distributor_migrate_incoming_posts',
'network_hub_name_updated',
'newspack_network_product_updated',
];
}
3 changes: 3 additions & 0 deletions includes/class-initializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@ public static function init() {
CLI\Integrity_Check::init();

Woocommerce\Events::init();
Woocommerce\Product_Admin::init();
Woocommerce_Subscriptions\My_Account::init();
Woocommerce_Memberships\Admin::init();
Woocommerce_Memberships\Events::init();
Woocommerce_Memberships\Subscriptions_Integration::init();
Woocommerce_Memberships\Limit_Purchase::init();
Content_Gate\Access::init();
Content_Gate\Limit_Purchase::init();

register_activation_hook( NEWSPACK_NETWORK_PLUGIN_FILE, [ __CLASS__, 'activation_hook' ] );
}
Expand Down
79 changes: 79 additions & 0 deletions includes/cli/backfillers/class-product-updated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php
/**
* Data Backfiller for product_updated events.
*
* @package Newspack
*/

namespace Newspack_Network\Backfillers;

use Newspack_Network\Woocommerce\Product_Admin;
use Newspack_Network\Woocommerce\Events as Woocommerce_Events;
use WP_CLI;

/**
* Backfiller class.
*/
class Product_Updated extends Abstract_Backfiller {

/**
* Gets the output line about the processed item being processed in verbose mode.
*
* @param \Newspack_Network\Incoming_Events\Abstract_Incoming_Event $event The event.
*
* @return string
*/
protected function get_processed_item_output( $event ) {
return sprintf( 'Product #%d', $event->get_id() );
}

/**
* Gets the events to be processed.
*
* @return \Newspack_Network\Incoming_Events\Abstract_Incoming_Event[] $events An array of events.
*/
public function get_events() {
if ( ! function_exists( 'wc_get_product' ) ) {
return [];
}

$products = get_posts(
[
'post_type' => 'product',
'post_status' => 'any',
'numberposts' => -1,
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
[
'key' => Product_Admin::NETWORK_ID_META_KEY,
'compare' => '!=',
'value' => '',
],
],
'date_query' => [
'column' => 'post_modified_gmt',
'after' => $this->start,
'before' => $this->end,
'inclusive' => true,
],
]
);

$this->maybe_initialize_progress_bar( 'Processing products', count( $products ) );

$events = [];
WP_CLI::line( '' );
WP_CLI::line( sprintf( 'Found %s product(s) with Network IDs eligible for sync.', count( $products ) ) );
WP_CLI::line( '' );

foreach ( $products as $product ) {
$product_data = Woocommerce_Events::product_updated( $product->ID );
if ( ! $product_data ) {
continue;
}
$timestamp = strtotime( $product->post_modified_gmt );
$events[] = new \Newspack_Network\Incoming_Events\Product_Updated( get_bloginfo( 'url' ), $product_data, $timestamp );
}

return $events;
}
}
164 changes: 164 additions & 0 deletions includes/content-gate/class-access.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php
/**
* Newspack Network Content Gate Access integration.
*
* Hooks into newspack-plugin's access rules to grant access
* when a user has an active subscription on another network site
* for a product with a matching Network ID.
*
* @package Newspack
*/

namespace Newspack_Network\Content_Gate;

use Newspack_Network\Incoming_Events\Subscription_Changed;
use Newspack_Network\Incoming_Events\Product_Updated;
use Newspack_Network\Woocommerce\Product_Admin;

/**
* Class to handle network-aware content gate access.
*/
class Access {

/**
* Initializer.
*/
public static function init() {
add_filter( 'newspack_access_rules_has_active_subscription', [ __CLASS__, 'check_network_subscriptions' ], 10, 3 );
}

/**
* Check if the user has an active subscription on another network site
* for a product with a matching Network ID.
*
* @param bool $has_subscription Whether the user already has an active subscription (from local checks).
* @param int $user_id User ID.
* @param array $product_ids Required product IDs (local).
* @return bool
*/
public static function check_network_subscriptions( $has_subscription, $user_id, $product_ids ) {
// If local check already passed, no need to check network.
if ( $has_subscription ) {
return true;
}

// If no products specified, we can't match by Network ID.
if ( empty( $product_ids ) ) {
return $has_subscription;
}

// Get Network IDs for the required local products.
$network_ids = self::get_network_ids_for_products( $product_ids );
if ( empty( $network_ids ) ) {
return $has_subscription;
}

// Get user's network subscriptions.
$network_subscriptions = self::get_user_network_active_subscriptions( $user_id );
if ( empty( $network_subscriptions ) ) {
return $has_subscription;
}

// Get synced product data from all network sites.
$network_products = get_option( Product_Updated::OPTION_NAME, [] );

// Check if any network subscription has a product with a matching Network ID.
foreach ( $network_subscriptions as $site => $subscriptions ) {
$site_products = $network_products[ $site ] ?? [];
foreach ( $subscriptions as $subscription ) {
foreach ( $subscription['products'] as $product ) {
// Cast to string to handle int/string key mismatch from JSON round-tripping.
$product_id = (string) $product['id'];
// Look up this product's Network ID from synced data.
$remote_network_id = $site_products[ $product_id ]['network_id'] ?? '';
if ( ! empty( $remote_network_id ) && in_array( $remote_network_id, $network_ids, true ) ) {
return true;
}
}
}
}

return $has_subscription;
}

/**
* Get Network IDs for the given local product IDs.
*
* @param array $product_ids Local product IDs.
* @return array Array of Network IDs (non-empty values only).
*/
public static function get_network_ids_for_products( $product_ids ) {
$network_ids = [];
foreach ( $product_ids as $product_id ) {
$network_id = Product_Admin::get_network_id( $product_id );
if ( ! empty( $network_id ) ) {
$network_ids[] = $network_id;
}
}
return array_unique( $network_ids );
}

/**
* Check if a user has an active network subscription for a given Network ID.
*
* @param int $user_id The user ID.
* @param string $network_id The product Network ID to match.
* @return array|false Array with 'site' and 'subscription' keys if found, false otherwise.
*/
public static function user_has_active_network_subscription_for_network_id( $user_id, $network_id ) {
$network_subscriptions = self::get_user_network_active_subscriptions( $user_id );
if ( empty( $network_subscriptions ) ) {
return false;
}

$network_products = get_option( Product_Updated::OPTION_NAME, [] );

foreach ( $network_subscriptions as $site => $subscriptions ) {
$site_products = $network_products[ $site ] ?? [];
foreach ( $subscriptions as $subscription ) {
foreach ( $subscription['products'] as $product ) {
// Cast to string to handle int/string key mismatch from JSON round-tripping.
$product_id = (string) $product['id'];
$remote_network_id = $site_products[ $product_id ]['network_id'] ?? '';
if ( ! empty( $remote_network_id ) && $remote_network_id === $network_id ) {
return [
'site' => $site,
'subscription' => $subscription,
];
}
}
}
}

return false;
}

/**
* Gets all active subscriptions for a user across all network sites.
*
* @param int $user_id The user ID.
* @return array An array with the site as key and an array of subscriptions as value.
*/
public static function get_user_network_active_subscriptions( $user_id ) {
$meta = get_user_meta( $user_id, Subscription_Changed::USER_SUBSCRIPTIONS_META_KEY, true );
if ( ! $meta ) {
return [];
}

$returned_subs = [];

foreach ( $meta as $site => $subscriptions ) {
$returned_subs[ $site ] = array_filter(
$subscriptions,
function ( $sub ) {
return in_array( $sub['status'], [ 'active', 'pending-cancel' ], true );
}
);
if ( empty( $returned_subs[ $site ] ) ) {
unset( $returned_subs[ $site ] );
}
}

return $returned_subs;
}
}
Loading
Loading