diff --git a/includes/class-accepted-actions.php b/includes/class-accepted-actions.php index bf4071d8..3457883a 100644 --- a/includes/class-accepted-actions.php +++ b/includes/class-accepted-actions.php @@ -35,6 +35,7 @@ class Accepted_Actions { 'donation_new' => 'Donation_New', 'donation_subscription_cancelled' => 'Donation_Subscription_Cancelled', 'network_user_updated' => 'User_Updated', + 'network_user_deleted' => 'User_Deleted', 'newspack_network_woo_membership_updated' => 'Woocommerce_Membership_Updated', 'network_manual_sync_user' => 'User_Manually_Synced', 'network_nodes_synced' => 'Nodes_Synced', @@ -53,6 +54,7 @@ class Accepted_Actions { 'donation_new', 'donation_subscription_cancelled', 'network_user_updated', + 'network_user_deleted', 'newspack_network_woo_membership_updated', 'network_manual_sync_user', 'network_nodes_synced', diff --git a/includes/class-admin.php b/includes/class-admin.php index 5ef77d1a..20abf1ed 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -198,11 +198,6 @@ public static function enqueue_scripts() { * @return bool True if experimental auditing features are enabled. */ public static function use_experimental_auditing_features() { - $user_slug = defined( 'NEWSPACK_NETWORK_EXPERIMENTAL_AUDITING_USER' ) ? NEWSPACK_NETWORK_EXPERIMENTAL_AUDITING_USER : false; - if ( ! $user_slug ) { - return false; - } - $user = get_user_by( 'login', $user_slug ); - return $user && get_current_user_id() === $user->ID; + return defined( 'NEWSPACK_NETWORK_EXPERIMENTAL_AUDITING_FEATURES' ) ? NEWSPACK_NETWORK_EXPERIMENTAL_AUDITING_FEATURES : false; } } diff --git a/includes/class-data-listeners.php b/includes/class-data-listeners.php index 6468c612..64cb0b86 100644 --- a/includes/class-data-listeners.php +++ b/includes/class-data-listeners.php @@ -36,6 +36,7 @@ public static function register_listeners() { Data_Events::register_listener( 'woocommerce_subscription_status_changed', 'newspack_node_subscription_changed', [ __CLASS__, 'item_changed' ] ); Data_Events::register_listener( 'woocommerce_order_status_changed', 'newspack_node_order_changed', [ __CLASS__, 'item_changed' ] ); Data_Events::register_listener( 'newspack_network_user_updated', 'network_user_updated', [ __CLASS__, 'user_updated' ] ); + Data_Events::register_listener( 'delete_user', 'network_user_deleted', [ __CLASS__, 'user_deleted' ] ); Data_Events::register_listener( 'newspack_network_nodes_synced', 'network_nodes_synced', [ __CLASS__, 'nodes_synced' ] ); } @@ -87,6 +88,26 @@ public static function user_updated( $user_data ) { return $user_data; } + /** + * Filters the user data for the event being triggered + * + * @param int $id ID of the user to delete. + * @param int|null $reassign ID of the user to reassign posts and links to. + * Default null, for no reassignment. + * @param WP_User $user WP_User object of the user to delete. + * @return array + */ + public static function user_deleted( $id, $reassign, $user ) { + $should_delete = apply_filters( 'newspack_network_process_user_deleted', true, $user->user_email ); + if ( ! $should_delete ) { + Debugger::log( 'User deletion with email: ' . $user->user_email . ' was skipped due to filter use.' ); + return; + } + return [ + 'email' => $user->user_email, + ]; + } + /** * Filters the nodes data for the event being triggered * diff --git a/includes/class-esp-metadata-sync.php b/includes/class-esp-metadata-sync.php index 20cabc3b..9f7f599e 100644 --- a/includes/class-esp-metadata-sync.php +++ b/includes/class-esp-metadata-sync.php @@ -7,6 +7,8 @@ namespace Newspack_Network; +use Newspack_Network\Utils\Users as User_Utils; + /** * Class to handle Node settings page */ @@ -21,6 +23,8 @@ public static function init() { \add_filter( 'newspack_ras_metadata_keys', [ __CLASS__, 'add_custom_metadata_fields' ] ); \add_filter( 'newspack_register_reader_metadata', [ __CLASS__, 'handle_custom_metadata_fields' ], 10, 2 ); \add_filter( 'newspack_data_events_reader_registered_metadata', [ __CLASS__, 'handle_custom_metadata_fields' ], 10, 2 ); + \add_action( 'newspack_network_network_reader', [ __CLASS__, 'handle_custom_metadata_for_network_readers' ] ); + \add_action( 'newspack_network_new_network_reader', [ __CLASS__, 'handle_custom_metadata_for_network_readers' ] ); } /** @@ -48,9 +52,62 @@ public static function add_custom_metadata_fields( $metadata_fields ) { */ public static function handle_custom_metadata_fields( $metadata, $user_id ) { if ( $user_id ) { - $metadata['network_registration_site'] = \esc_url( \get_site_url() ); + $remote_site = \get_user_meta( $user_id, User_Utils::USER_META_REMOTE_SITE, true ); + $registration_site = \esc_url( ! empty( \wp_http_validate_url( $remote_site ) ) ? $remote_site : \get_site_url() ); + $metadata['network_registration_site'] = $registration_site; } return $metadata; } + + /** + * Trigger a reader data sync to the connected ESP. + * + * @param array $contact The contact data to sync. + */ + public static function sync_contact( $contact ) { + // Only if Reader Activation and Newspack Newsletters are available. + if ( ! class_exists( 'Newspack\Reader_Activation' ) || ! method_exists( 'Newspack_Newsletters', 'service_provider' ) ) { + return; + } + + // Only if RAS + ESP sync is enabled. + if ( ! \Newspack\Reader_Activation::is_enabled() || ! \Newspack\Reader_Activation::get_setting( 'sync_esp' ) ) { + return; + } + + // Only if we have the ESP Data Events connectors. + if ( ! class_exists( 'Newspack\Data_Events\Connectors\Mailchimp' ) || ! class_exists( 'Newspack\Data_Events\Connectors\ActiveCampaign' ) ) { + return; + } + + $service_provider = \Newspack_Newsletters::service_provider(); + if ( 'mailchimp' === $service_provider ) { + return \Newspack\Data_Events\Connectors\Mailchimp::put( $contact ); + } elseif ( 'active_campaign' === $service_provider ) { + return \Newspack\Data_Events\Connectors\ActiveCampaign::put( $contact ); + } + } + + /** + * Sync custom metadata fields for network readers. + * + * @param WP_User $user The newly created or existing user. + */ + public static function handle_custom_metadata_for_network_readers( $user ) { + if ( ! $user ) { + return; + } + $contact = \Newspack\WooCommerce_Connection::get_contact_from_customer( new \WC_Customer( $user->ID ) ); + $metadata = $contact['metadata'] ?? []; + + // Ensure email is set as the user probably won't have a billing email. + if ( ! isset( $contact['email'] ) ) { + $contact['email'] = $user->user_email; + } + + $contact['metadata'] = self::handle_custom_metadata_fields( $metadata, $user->ID ); + + self::sync_contact( $contact ); + } } diff --git a/includes/class-initializer.php b/includes/class-initializer.php index 758e9687..7e78c80b 100644 --- a/includes/class-initializer.php +++ b/includes/class-initializer.php @@ -24,9 +24,12 @@ public static function init() { Hub\Nodes::init(); Hub\Webhook::init(); Hub\Pull_Endpoint::init(); + Hub\Network_Data_Endpoint::init(); Hub\Event_Listeners::init(); - Hub\Database\Subscriptions::init(); - Hub\Database\Orders::init(); + // phpcs:disable + // Hub\Database\Subscriptions::init(); + // Hub\Database\Orders::init(); + // phpcs:enable Hub\Newspack_Ads_GAM::init(); Hub\Connect_Node::init(); } @@ -53,6 +56,7 @@ public static function init() { Synchronize_All::init(); Data_Backfill::init(); + Misc::init(); Membership_Dedupe::init(); Woocommerce_Memberships\Admin::init(); diff --git a/includes/class-rest-authenticaton.php b/includes/class-rest-authenticaton.php index 8cd11a80..3c223e5a 100644 --- a/includes/class-rest-authenticaton.php +++ b/includes/class-rest-authenticaton.php @@ -37,6 +37,10 @@ class Rest_Authenticaton { 'endpoint' => '|^/wc/v2/memberships/plans|', 'callback' => [ __CLASS__, 'add_filter_for_woo_read_endpoints' ], ], + 'get-woo-memberships' => [ + 'endpoint' => '|^/wc/v2/memberships|', + 'callback' => [ __CLASS__, 'add_filter_for_woo_read_endpoints' ], + ], ]; /** diff --git a/includes/class-users.php b/includes/class-users.php index d3d48d3b..f5391d99 100644 --- a/includes/class-users.php +++ b/includes/class-users.php @@ -7,6 +7,8 @@ namespace Newspack_Network; +use const Newspack_Network\constants\EVENT_LOG_PAGE_SLUG; + /** * Class to handle the Users admin page */ @@ -17,7 +19,7 @@ class Users { */ public static function init() { add_filter( 'manage_users_columns', [ __CLASS__, 'manage_users_columns' ] ); - add_filter( 'manage_users_custom_column', [ __CLASS__, 'manage_users_custom_column' ], 10, 3 ); + add_filter( 'manage_users_custom_column', [ __CLASS__, 'manage_users_custom_column' ], 99, 3 ); // priority must be higher than Jetpack's jetpack_show_connection_status (10). add_filter( 'users_list_table_query_args', [ __CLASS__, 'users_list_table_query_args' ] ); } @@ -28,9 +30,7 @@ public static function init() { * @return array */ public static function manage_users_columns( $columns ) { - if ( Site_Role::is_hub() ) { - $columns['newspack_network_activity'] = __( 'Newspack Network Activity', 'newspack-network' ); - } + $columns['newspack_network_activity'] = __( 'Newspack Network Activity', 'newspack-network' ); if ( \Newspack_Network\Admin::use_experimental_auditing_features() ) { $columns['newspack_network_user'] = __( 'Network Original User', 'newspack-network' ); } @@ -58,36 +58,45 @@ public static function manage_users_custom_column( $value, $column_name, $user_i ); } } - if ( 'newspack_network_activity' === $column_name && Site_Role::is_hub() ) { + if ( 'newspack_network_activity' === $column_name ) { $user = get_user_by( 'id', $user_id ); if ( ! $user ) { return $value; } + if ( Site_Role::is_hub() ) { + $last_activity = \Newspack_Network\Hub\Stores\Event_Log::get( [ 'email' => $user->user_email ], 1 ); + if ( empty( $last_activity ) ) { + return '-'; + } - $last_activity = \Newspack_Network\Hub\Stores\Event_Log::get( [ 'email' => $user->user_email ], 1 ); - - if ( empty( $last_activity ) ) { - return '-'; + $event_log_url = add_query_arg( + [ + 'page' => EVENT_LOG_PAGE_SLUG, + 'email' => urlencode( $user->user_email ), + ], + admin_url( 'admin.php' ) + ); + return sprintf( + '%s: %s
%s', + __( 'Last Activity', 'newspack-network' ), + $last_activity[0]->get_summary(), + $event_log_url, + __( 'View all', 'newspack-network' ) + ); + } else { + $event_log_url = add_query_arg( + [ + 'page' => EVENT_LOG_PAGE_SLUG, + 'email' => urlencode( $user->user_email ), + ], + untrailingslashit( Node\Settings::get_hub_url() ) . '/wp-admin/admin.php' + ); + return sprintf( + '%s', + $event_log_url, + __( 'View activity', 'newspack-network' ) + ); } - - $last_activity = $last_activity[0]; - - $summary = $last_activity->get_summary(); - $event_log_url = add_query_arg( - [ - 'page' => \Newspack_Network\Hub\Admin\Event_Log::PAGE_SLUG, - 'email' => $user->user_email, - ], - admin_url( 'admin.php' ) - ); - return sprintf( - '%s: %s
%s', - __( 'Last Activity', 'newspack-network' ), - $summary, - $event_log_url, - __( 'View all', 'newspack-network' ) - ); - } return $value; } @@ -104,6 +113,10 @@ public static function users_list_table_query_args( $args ) { $args['role__in'] = explode( ',', sanitize_text_field( $_REQUEST['role__in'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended unset( $args['role'] ); } + if ( isset( $_REQUEST['role__not_in'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $args['role__not_in'] = explode( ',', sanitize_text_field( $_REQUEST['role__not_in'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + unset( $args['role'] ); + } return $args; } } diff --git a/includes/cli/backfillers/class-reader-registered.php b/includes/cli/backfillers/class-reader-registered.php index 7d13a1ea..4e291d30 100644 --- a/includes/cli/backfillers/class-reader-registered.php +++ b/includes/cli/backfillers/class-reader-registered.php @@ -35,7 +35,7 @@ public function get_events() { if ( empty( $roles_to_sync ) ) { WP_CLI::error( 'Incompatible Newspack plugin version or no roles to sync.' ); } - // Get all users registered between this-> and $end. + // Get all users registered between specified dates. $users = get_users( [ 'role__in' => $roles_to_sync, @@ -44,6 +44,7 @@ public function get_events() { 'before' => $this->end, 'inclusive' => true, ], + 'orderby' => 'user_registered', 'fields' => [ 'id', 'user_email', 'user_registered' ], 'number' => -1, 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query @@ -55,7 +56,9 @@ public function get_events() { ] ); + WP_CLI::line( '' ); WP_CLI::line( sprintf( 'Found %s user(s) eligible for sync.', count( $users ) ) ); + WP_CLI::line( '' ); $this->maybe_initialize_progress_bar( 'Processing users', count( $users ) ); @@ -76,9 +79,12 @@ function( $args ) { foreach ( $users as $user ) { $registration_method = get_user_meta( $user->ID, \Newspack\Reader_Activation::REGISTRATION_METHOD, true ); $user_data = [ - 'user_id' => $user->ID, - 'email' => $user->user_email, - 'metadata' => [ + 'user_id' => $user->ID, + 'email' => $user->user_email, + 'user_registered' => $user->user_registered, + 'first_name' => get_user_meta( $user->ID, 'first_name', true ), + 'last_name' => get_user_meta( $user->ID, 'last_name', true ), + 'meta_input' => [ // 'current_page_url' is not saved, can't be backfilled. 'registration_method' => empty( $registration_method ) ? 'backfill-script' : $registration_method, ], diff --git a/includes/cli/backfillers/class-woocommerce-membership-updated.php b/includes/cli/backfillers/class-woocommerce-membership-updated.php index 1939654d..5b3d08c6 100644 --- a/includes/cli/backfillers/class-woocommerce-membership-updated.php +++ b/includes/cli/backfillers/class-woocommerce-membership-updated.php @@ -58,6 +58,9 @@ public function get_events() { $this->maybe_initialize_progress_bar( 'Processing memberships', count( $membership_posts_ids ) ); $events = []; + WP_CLI::line( '' ); + WP_CLI::line( sprintf( 'Found %s membership(s) eligible for sync.', count( $membership_posts_ids ) ) ); + WP_CLI::line( '' ); foreach ( $membership_posts_ids as $post_id ) { $membership = new \WC_Memberships_User_Membership( $post_id ); @@ -77,10 +80,21 @@ public function get_events() { 'membership_id' => $membership->get_id(), 'new_status' => $status, ]; - if ( $status === 'active' ) { + $timestamp = null; + switch ( $status ) { + case 'paused': + $timestamp = strtotime( $membership->get_paused_date() ); + break; + case 'cancelled': + $timestamp = strtotime( $membership->get_cancelled_date() ); + break; + case 'expired': + $timestamp = strtotime( $membership->get_end_date() ); + break; + } + + if ( ! $timestamp ) { $timestamp = strtotime( $membership->get_start_date() ); - } else { - $timestamp = strtotime( $membership->get_end_date() ); } $events[] = new \Newspack_Network\Incoming_Events\Woocommerce_Membership_Updated( get_bloginfo( 'url' ), $membership_data, $timestamp ); diff --git a/includes/cli/class-membership-dedupe.php b/includes/cli/class-membership-dedupe.php index 1b9a3398..9d209e2d 100644 --- a/includes/cli/class-membership-dedupe.php +++ b/includes/cli/class-membership-dedupe.php @@ -39,7 +39,7 @@ public static function register_commands() { [ 'type' => 'assoc', 'name' => 'plan-id', - 'optional' => false, + 'optional' => true, ], [ 'type' => 'flag', @@ -64,62 +64,97 @@ public static function register_commands() { * * wp newspack-network clean-up-duplicate-memberships --plan-id=1234 * + * ## OPTIONS + * + * [--plan-id] + * : Membership plan ID to clean up. If not set, all synchronized plans will be cleaned up. + * + * [--live] + * : Run the command in live mode, updating the users. + * + * [--csv] + * : Output CSV. + * * @param array $args Positional args. * @param array $assoc_args Associative args and flags. */ public static function clean_duplicate_memberships( $args, $assoc_args ) { WP_CLI::line( '' ); - $live = isset( $assoc_args['live'] ); - $csv = isset( $assoc_args['csv'] ); - - $plan_id = $assoc_args['plan-id']; - if ( ! is_numeric( $plan_id ) ) { - WP_CLI::error( 'Membership plan ID must be numeric' ); - } - $plan_id = (int) $plan_id; + $live = isset( $assoc_args['live'] ); if ( ! $live ) { WP_CLI::line( 'Running in dry-run mode. Use --live flag to run in live mode.' ); WP_CLI::line( '' ); } - $user_ids = self::get_users_with_duplicate_membership( $plan_id ); - WP_CLI::line( sprintf( '%d users found with duplicate memberships', count( $user_ids ) ) ); + $csv = isset( $assoc_args['csv'] ); - $duplicates = []; - foreach ( $user_ids as $user_id ) { - $memberships = get_posts( + $plan_id_from_args = isset( $assoc_args['plan-id'] ) ? $assoc_args['plan-id'] : null; + $plan_ids = []; + if ( $plan_id_from_args ) { + if ( ! is_numeric( $plan_id_from_args ) ) { + WP_CLI::error( 'Membership plan ID must be numeric' ); + } + $plan_ids = [ (int) $plan_id_from_args ]; + } else { + // Get all network-sync'd membership plans. + $plan_ids = get_posts( [ - 'author' => $user_id, - 'post_type' => 'wc_user_membership', - 'post_status' => 'any', - 'post_parent' => $plan_id, + 'post_type' => 'wc_membership_plan', + 'fields' => 'ids', + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => \Newspack_Network\Woocommerce_Memberships\Admin::NETWORK_ID_META_KEY, + 'compare' => 'EXISTS', + ], + ], ] ); + } - foreach ( $memberships as $membership ) { - $user = get_user_by( 'id', $membership->post_author ); - $duplicates[] = [ - 'user' => $membership->post_author, - 'email' => $user->user_email, - 'membership' => $membership->ID, - 'subscription' => get_post_meta( $membership->ID, '_subscription_id', true ), - 'status' => $membership->post_status, - 'remote' => get_post_meta( $membership->ID, '_remote_site_url', true ), - ]; + $user_ids = []; + foreach ( $plan_ids as $plan_id ) { + WP_CLI::line( sprintf( 'Checking plan #%d', $plan_id ) ); + + $user_ids = array_merge( $user_ids, self::get_users_with_duplicate_membership( $plan_id ) ); + WP_CLI::line( sprintf( '%d users found with duplicate memberships', count( $user_ids ) ) ); + + $duplicates = []; + foreach ( $user_ids as $user_id ) { + $memberships = get_posts( + [ + 'author' => $user_id, + 'post_type' => 'wc_user_membership', + 'post_status' => 'any', + 'post_parent' => $plan_id, + ] + ); + + foreach ( $memberships as $membership ) { + $user = get_user_by( 'id', $membership->post_author ); + if ( $user === false ) { + continue; + } + $duplicates[] = [ + 'user' => $membership->post_author, + 'email' => $user->user_email, + 'membership' => $membership->ID, + 'subscription' => get_post_meta( $membership->ID, '_subscription_id', true ), + 'status' => $membership->post_status, + 'remote' => get_post_meta( $membership->ID, '_remote_site_url', true ), + ]; + } } - } - if ( $csv && ! empty( $duplicates ) ) { - WP_CLI::line( 'COPY AND PASTE THIS CSV: ' ); - WP_CLI::line(); - WP_CLI\Utils\format_items( 'csv', $duplicates, array_keys( $duplicates[0] ) ); - WP_CLI::line(); - } + if ( $csv && ! empty( $duplicates ) ) { + WP_CLI::line( 'COPY AND PASTE THIS CSV: ' ); + WP_CLI::line(); + WP_CLI\Utils\format_items( 'csv', $duplicates, array_keys( $duplicates[0] ) ); + WP_CLI::line(); + } - if ( $live ) { - WP_CLI::line( 'Deleting duplicates' ); - self::deduplicate_memberships( $duplicates ); + self::deduplicate_memberships( $duplicates, $live ); + WP_CLI::line( '' ); } WP_CLI::success( 'Done' ); @@ -151,8 +186,12 @@ private static function get_users_with_duplicate_membership( $plan_id ) { * De-duplicate memberships so that users only have one membership of a plan. * * @param array $duplicates Analyzed data from ::clean_duplicate_memberships. + * @param bool $live Whether to actually delete the duplicates. */ - private static function deduplicate_memberships( $duplicates ) { + private static function deduplicate_memberships( $duplicates, $live ) { + if ( $live ) { + WP_CLI::line( 'Deleting duplicates' ); + } $userdata = []; foreach ( $duplicates as $duplicate ) { @@ -166,13 +205,17 @@ private static function deduplicate_memberships( $duplicates ) { foreach ( $userdata as $email => $duplicates ) { WP_CLI::line( sprintf( 'Processing %s', $email ) ); if ( count( $duplicates ) < 2 ) { - WP_CLI::line( ' - User does not have too many memberships' ); + WP_CLI::line( ' - User has multiple memberships, but no duplicates' ); } $memberships_to_delete = array_slice( $duplicates, 1 ); foreach ( $memberships_to_delete as $duplicate ) { - wp_delete_post( $duplicate['membership'], true ); - WP_CLI::line( sprintf( ' - Deleted extra membership %d', $duplicate['membership'] ) ); + if ( $live ) { + wp_delete_post( $duplicate['membership'], true ); + WP_CLI::line( sprintf( ' - Deleted extra membership %d', $duplicate['membership'] ) ); + } else { + WP_CLI::line( sprintf( ' - Would have deleted extra membership %d', $duplicate['membership'] ) ); + } } } } diff --git a/includes/cli/class-misc.php b/includes/cli/class-misc.php new file mode 100644 index 00000000..967ca0cb --- /dev/null +++ b/includes/cli/class-misc.php @@ -0,0 +1,821 @@ + [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + [ + 'key' => 'wp_capabilities', + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => 'wp_capabilities', + 'value' => 'a:0:{}', + 'compare' => '=', + ], + ], + ] + ); + } + WP_CLI::line( 'Got ' . count( $users_to_update ) . ' users to update.' ); + WP_CLI::line( '' ); + + foreach ( $users_to_update as $user ) { + // If read from CSV, find the user by email. + if ( is_array( $user ) ) { + $user_email = $user['user_email']; + $user = get_user_by( 'email', $user_email ); + if ( $user === false ) { + WP_CLI::warning( 'User not found by email: ' . $user_email ); + continue; + } + } + $user_id = $user->ID; + $role = 'subscriber'; + // If the user has the newspack_remote_site meta, it's a network user. + $remote_site = get_user_meta( $user_id, \Newspack_Network\Utils\Users::USER_META_REMOTE_SITE, true ); + if ( $remote_site ) { + $role = NEWSPACK_NETWORK_READER_ROLE; + } + if ( $live ) { + $user->set_role( $role ); + WP_CLI::line( "šŸ‘‰ Assigned '$role' role to user $user->user_email (#$user_id)." ); + } else { + WP_CLI::line( "šŸ‘‰ In live mode, would assign '$role' role to user $user->user_email (#$user_id)." ); + } + } + + WP_CLI::line( '' ); + } + + /** + * Get user memberships. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## EXAMPLES + * + * wp newspack-network get-user-memberships member-name@example.com + */ + public static function get_user_memberships( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + + $email_address = isset( $args[0] ) ? $args[0] : false; + if ( ! $email_address ) { + WP_CLI::error( 'Please provide an email address.' ); + } + + $memberships = \wc_memberships_get_user_memberships( get_user_by( 'email', $email_address ) ); + foreach ( $memberships as $membership ) { + $plan_name = $membership->get_plan()->get_name(); + $plan_network_id = get_post_meta( $membership->get_plan()->get_id(), \Newspack_Network\Woocommerce_Memberships\Admin::NETWORK_ID_META_KEY, true ); + if ( $plan_network_id ) { + $plan_name .= ' (Network ID: ' . $plan_network_id . ')'; + } + + WP_CLI::line( 'āž” Membership ID: ' . $membership->get_id() . ', status: ' . $membership->get_status() . ', plan: ' . $plan_name ); + $subscription = ( new \WC_Memberships_Integration_Subscriptions_User_Membership( $membership->post ) )->get_subscription(); + if ( $subscription ) { + WP_CLI::line( sprintf( ' āž” linked with %s subscription #%d for amount: %d', $subscription->get_status(), $subscription->get_id(), $subscription->get_total() ) ); + } + } + + WP_CLI::line( '' ); + } + + /** + * Fix registration-based memberships. All registered users should have a membership if it's + * set to be created on registration. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## OPTIONS + * + * [--live] + * : Run the command in live mode, updating the users. + * + * [--verbose] + * : More output. + * + * ## EXAMPLES + * + * wp newspack-network fix-registration-based-memberships + */ + public static function fix_registration_based_memberships( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + + $live = isset( $assoc_args['live'] ) ? true : false; + $verbose = isset( $assoc_args['verbose'] ) ? true : false; + if ( $live ) { + WP_CLI::line( 'Live mode ā€“ memberships will be created.' ); + } else { + WP_CLI::line( 'Dry run ā€“ memberships will not be created. Use --live flag to run in live mode.' ); + } + WP_CLI::line( '' ); + + global $wpdb; + $user_emails_with_ids = $wpdb->get_results( 'SELECT ID, user_email FROM wp_users WHERE user_email != ""' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + // Turn it into an associative array, keyed by email. + $user_emails_with_ids = array_combine( + array_map( + function( $user ) { + return $user->user_email; + }, + $user_emails_with_ids + ), + array_map( + function( $user ) { + return $user->ID; + }, + $user_emails_with_ids + ) + ); + $user_emails = array_keys( $user_emails_with_ids ); + + WP_CLI::line( 'Found ' . count( $user_emails ) . ' users.' ); + WP_CLI::line( '' ); + + // Get the registration-based memberships. + $plans = \wc_memberships_get_membership_plans(); + foreach ( $plans as $plan ) { + if ( $plan->get_access_method() === 'signup' ) { + $memberships = $plan->get_memberships(); + + WP_CLI::line( 'Found ' . count( $memberships ) . ' memberships for plan ' . $plan->get_name() . '.' ); + + // Get email addresses of all members. + foreach ( $memberships as $membership ) { + $user_id = $membership->get_user_id(); + // Get user email directly from the DB for performance reasons. + $user_email = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + 'SELECT user_email FROM wp_users WHERE ID = %d', + $user_id + ) + ); + if ( $user_email ) { + // Remove this user from the list of all users. + $key = array_search( $user_email, $user_emails ); + if ( $key !== false ) { + unset( $user_emails[ $key ] ); + } + } + } + + if ( count( $user_emails ) > 0 ) { + if ( $live ) { + WP_CLI::line( 'Will create memberships for the ' . count( $user_emails ) . ' missing users.' ); + } else { + WP_CLI::line( 'Would create memberships for the ' . count( $user_emails ) . ' missing users in live mode.' ); + } + WP_CLI::line( '' ); + foreach ( $user_emails as $email ) { + // Create the membership for the user. + $user_id = $user_emails_with_ids[ $email ]; + if ( $verbose ) { + if ( $live ) { + WP_CLI::line( "Creating membership for user $email (#$user_id)." ); + } else { + WP_CLI::line( "Would create membership for user $email (#$user_id)." ); + } + } + if ( $live ) { + wc_memberships_create_user_membership( + [ + 'plan_id' => $plan->get_id(), + 'user_id' => $user_id, + ] + ); + } + } + } + } + } + + WP_CLI::line( '' ); + } + + /** + * Fix membership discrepancies. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## OPTIONS + * + * [--live] + * : Run the command in live mode, updating the users. + * + * ## EXAMPLES + * + * wp newspack-network fix-membership-discrepancies + */ + public static function fix_membership_discrepancies( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + + $live = isset( $assoc_args['live'] ) ? true : false; + if ( $live ) { + WP_CLI::line( 'Live mode ā€“ memberships will be updated or created.' ); + } else { + WP_CLI::line( 'Dry run ā€“ memberships will not be updated. Use --live flag to run in live mode.' ); + } + + $membership_plans_from_network_data = \Newspack_Network\Hub\Admin\Membership_Plans::get_membership_plans_from_network(); + if ( ! isset( $membership_plans_from_network_data['discrepancies_emails'] ) ) { + WP_CLI::error( 'Missing discrepancies emails in network memberships data.' ); + } + WP_CLI::line( sprintf( 'Found %d discrepant email(s).', count( $membership_plans_from_network_data['discrepancies_emails'] ) ) ); + $by_origin_site = []; + foreach ( $membership_plans_from_network_data['discrepancies_emails'] as $email_address ) { + $user = get_user_by( 'email', $email_address ); + if ( ! $user ) { + WP_CLI::warning( 'User not found by email: ' . $email_address ); + continue; + } + $origin_site = get_user_meta( $user->ID, \Newspack_Network\Utils\Users::USER_META_REMOTE_SITE, true ); + if ( ! $origin_site ) { + $origin_site = 'hub'; + } + if ( isset( $by_origin_site[ $origin_site ] ) ) { + $by_origin_site[ $origin_site ][] = $email_address; + } else { + $by_origin_site[ $origin_site ] = [ $email_address ]; + } + } + + WP_CLI::line( '' ); + foreach ( $by_origin_site as $site_url => $user_emails ) { + WP_CLI::line( sprintf( 'Found %d user(s) originating from site %s', count( $user_emails ), $site_url ) ); + + foreach ( $user_emails as $user_email ) { + WP_CLI::line( sprintf( 'Fixing %sā€¦', $user_email ) ); + + if ( $site_url === 'hub' ) { + $found_memberships = \Newspack_Network\Woocommerce_Memberships\Admin::get_memberships_by_user_email_with_plan_network_id( $user_email ); + } else { + $found_memberships = \Newspack_Network\Hub\Admin\Membership_Plans::fetch_collection_from_api( + \Newspack_Network\Hub\Nodes::get_node_by_url( $site_url ), + 'wc/v2/memberships/members', + 'memberships', + [ + 'customer' => urlencode( $user_email ), + ] + ); + } + + if ( ! $found_memberships ) { + WP_CLI::warning( 'Could not retrieve any user memberships.' ); + continue; + } + foreach ( $found_memberships as $membership ) { + if ( ! $membership->plan_network_id ) { + continue; + } + WP_CLI::line( sprintf( 'Found membership #%d with status %s.', $membership->id, $membership->status ) ); + $membership_data = [ + 'email' => $user_email, + 'user_id' => $membership->customer_id, + 'plan_network_id' => $membership->plan_network_id, + 'membership_id' => $membership->id, + 'new_status' => $membership->status, + ]; + $timestamp = false; + switch ( $membership->status ) { + case 'paused': + $timestamp = strtotime( $membership->paused_date ); + break; + case 'cancelled': + $timestamp = strtotime( $membership->cancelled_date ); + break; + case 'expired': + $timestamp = strtotime( $membership->end_date ); + break; + } + + if ( ! $timestamp ) { + $timestamp = strtotime( $membership->start_date ); + } + + $event = new \Newspack_Network\Incoming_Events\Woocommerce_Membership_Updated( $site_url, $membership_data, $timestamp ); + + if ( $live ) { + if ( Site_Role::is_hub() ) { + $event->process_in_hub(); + if ( $event->is_persisted ) { + WP_CLI::success( 'Created and processed event.' ); + } else { + WP_CLI::warning( 'Event not persisted, possibly a duplicate.' ); + } + Data_Backfill::increment_results_counter( $event->get_action_name(), $event->is_persisted ? 'processed' : 'duplicate' ); + } else { + \Newspack\Data_Events\Webhooks::handle_dispatch( $event->get_action_name(), $event->get_timestamp(), $event->get_data() ); + WP_CLI::success( 'Dispatched webhook.' ); + } + } else { + WP_CLI::line( 'Would create and process the event in live mode.' ); + } + } + } + WP_CLI::line( '' ); + } + + WP_CLI::line( '' ); + } + + /** + * Display user discrepancies. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## EXAMPLES + * + * wp newspack-network display-user-discrepancies + */ + public static function display_user_discrepancies( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + + if ( ! Site_Role::is_hub() ) { + WP_CLI::error( 'This command can only be run on the Hub.' ); + } + + $user_emails_on_hub = \Newspack_Network\Utils\Users::get_synchronized_users_emails(); + WP_CLI::line( sprintf( 'Found %d synchronizable user(s) on the Hub.', count( $user_emails_on_hub ) ) ); + $user_not_synced_emails_on_hub = \Newspack_Network\Utils\Users::get_not_synchronized_users_emails(); + WP_CLI::line( sprintf( 'Found %d synchronizable user(s) on the Hub.', count( $user_not_synced_emails_on_hub ) ) ); + $no_role_users_emails = \Newspack_Network\Utils\Users::get_no_role_users_emails(); + WP_CLI::line( sprintf( 'Found %d no-role user(s) on the Hub.', count( $no_role_users_emails ) ) ); + + WP_CLI::line( '' ); + + $nodes = \Newspack_Network\Hub\Nodes::get_all_nodes(); + $users_by_site = [ + 'hub' => [ + 'not_synced' => $user_not_synced_emails_on_hub, + 'no_role' => $no_role_users_emails, + 'synced' => $user_emails_on_hub, + ], + ]; + $all_discrepant_emails = []; + foreach ( $nodes as $node ) { + $url = $node->get_url(); + + $site_info = $node->get_site_info(); + if ( $site_info === null ) { + WP_CLI::warning( 'Missing user data for site ' . $url ); + WP_CLI::line( '' ); + continue; + } + + $users_by_site[ $url ] = [ + 'not_synced' => $site_info->not_sync_users_emails, + 'no_role' => $site_info->no_role_users_emails, + 'synced' => $site_info->sync_users_emails, + ]; + + // Users who are on the Hub but not on the Node. + $not_on_node = array_diff( $user_emails_on_hub, $site_info->sync_users_emails ); + // Users who are not on the Node but are on the Hub. + $not_on_hub = array_diff( $site_info->sync_users_emails, $user_emails_on_hub ); + WP_CLI::line( 'Site: ' . $url ); + WP_CLI::line( + sprintf( '%1$d on the Hub only, %2$d on Node only', count( $not_on_hub ), count( $not_on_node ) ) + ); + WP_CLI::line( sprintf( 'Found %d no-role user(s).', count( $site_info->no_role_users_emails ) ) ); + + $unique_synced_email_addresses = array_unique( $site_info->sync_users_emails ); + WP_CLI::line( sprintf( 'Found %d synced email addresses (%d unique).', count( $site_info->sync_users_emails ), count( $unique_synced_email_addresses ) ) ); + if ( in_array( '', $site_info->sync_users_emails ) ) { + WP_CLI::warning( 'Empty email address found.' ); + } + if ( count( $unique_synced_email_addresses ) !== count( $site_info->sync_users_emails ) ) { + $counts = array_count_values( $site_info->sync_users_emails ); + foreach ( $counts as $email => $count ) { + if ( $count > 1 ) { + WP_CLI::warning( 'Duplicate email address: ' . $email ); + } + } + } + + $all_discrepant_emails = array_unique( array_merge( $all_discrepant_emails, $not_on_node, $not_on_hub ) ); + + WP_CLI::line( '' ); + } + + WP_CLI::line( 'Unique discrepant emails in total: ' . count( $all_discrepant_emails ) ); + WP_CLI::line( '' ); + + $users_to_handle = $all_discrepant_emails; + + // For each user, check who they are on all sites. + foreach ( $users_to_handle as $email_address ) { + if ( ! $email_address ) { + continue; + } + $user_record = []; + foreach ( $users_by_site as $site_url => $email_groups ) { + foreach ( $email_groups as $key => $users ) { + if ( in_array( $email_address, $users ) ) { + $user_record[ $site_url ] = $key; + } + } + } + $user_sites = array_keys( $user_record ); + $all_states = array_unique( array_values( $user_record ) ); + $wp_user = get_user_by( 'email', $email_address ); + $user_identification = $email_address; + if ( $wp_user ) { + $origin_site = get_user_meta( $wp_user->ID, \Newspack_Network\Utils\Users::USER_META_REMOTE_SITE, true ); + if ( $origin_site ) { + $user_identification = sprintf( '%s (%s)', $email_address, $origin_site ); + } + } + + if ( empty( $user_record ) ) { + WP_CLI::warning( sprintf( 'User %s is not present on any site.', $user_identification ) ); + WP_CLI::line( '' ); + } elseif ( ! in_array( 'synced', $all_states ) ) { + WP_CLI::line( sprintf( 'User %s is not synced on any site, they won\'t cause discrepancies.', $user_identification ) ); + WP_CLI::line( '' ); + } elseif ( count( $user_sites ) === 1 ) { + WP_CLI::success( sprintf( 'User %s is synced on a single site only (%s).', $user_identification, $user_sites[0] ) ); + WP_CLI::line( '' ); + } else { + if ( count( $user_sites ) === 8 ) { + WP_CLI::success( sprintf( 'User %s is synced on all sites.', $user_identification ) ); + } else { + WP_CLI::success( sprintf( 'User %s is present on the following sites:', $user_identification ) ); + } + foreach ( $user_record as $url => $key ) { + WP_CLI::line( sprintf( '%s on site %s', $key, $url ) ); + } + WP_CLI::line( '' ); + } + } + + WP_CLI::line( '' ); + } + + + /** + * Deduplicate users on a site. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## OPTIONS + * + * [--live] + * : Run the command in live mode, updating the users. + * + * ## EXAMPLES + * + * wp newspack-network deduplicate-users + */ + public static function deduplicate_users( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + + $live = isset( $assoc_args['live'] ) ? true : false; + if ( $live ) { + WP_CLI::line( 'Live mode ā€“ users will be deleted.' ); + } else { + WP_CLI::line( 'Dry run ā€“ users will not be deleted. Use --live flag to run in live mode.' ); + } + + global $wpdb; + $duplicate_users_result = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + 'SELECT user_email, ID, COUNT(user_email) as count FROM wp_users GROUP BY user_email HAVING count > 1' + ); + WP_CLI::line( sprintf( 'Found %d duplicated user(s)', count( $duplicate_users_result ) ) ); + WP_CLI::line( '' ); + foreach ( $duplicate_users_result as $key => $result ) { + if ( empty( $result->user_email ) ) { + WP_CLI::warning( 'No email address for user #' . $result->ID ); + WP_CLI::line( '' ); + continue; + } + WP_CLI::line( 'Email address: ' . $result->user_email ); + $user_ids_results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + 'SELECT id FROM wp_users WHERE user_email = %s', + $result->user_email + ) + ); + $users = []; + foreach ( $user_ids_results as $key => $result ) { + $users[] = get_user_by( 'id', $result->id ); + } + $by_user = []; + foreach ( $users as $user ) { + $memberships = \wc_memberships_get_user_memberships( $user ); + $by_user[ $user->ID ] = [ + 'memberships' => $memberships, + 'user' => $user, + ]; + if ( count( $memberships ) === 0 ) { + WP_CLI::line( sprintf( 'User %d has no memberships', $user->ID ) ); + } else { + $statuses = array_map( + function( $membership ) { + return $membership->status; + }, + $memberships + ); + $plan_ids = array_map( + function( $membership ) { + return $membership->plan_id; + }, + $memberships + ); + WP_CLI::line( sprintf( 'User %d has %d membership(s) (plans: %s) (statuses: %s)', $user->ID, count( $memberships ), implode( ', ', $plan_ids ), implode( ', ', $statuses ) ) ); + } + } + + // Get the user with the least memberships. + $min_memberships = PHP_INT_MAX; + $min_user_id = null; + foreach ( $by_user as $user_id => $user_data ) { + $memberships = $user_data['memberships']; + if ( count( $memberships ) < $min_memberships ) { + $min_memberships = count( $memberships ); + $min_user_id = $user_id; + } + } + if ( $live ) { + WP_CLI::line( 'Removing user #' . $min_user_id ); + // If removing duplicates, the user-deleted event should not be handled. Otherwise, + // the "original" user would be removed from the network. + add_filter( 'newspack_network_process_user_deleted', '__return_false' ); + $result = wp_delete_user( $min_user_id ); + remove_filter( 'newspack_network_process_user_deleted', '__return_false' ); + if ( $result ) { + WP_CLI::success( 'Deleted user ' . $min_user_id ); + } else { + WP_CLI::warning( 'Failed to delete user ' . $min_user_id ); + } + } else { + WP_CLI::line( 'Would remove user #' . $min_user_id ); + } + + WP_CLI::line( '' ); + } + } + + /** + * Deduplicate subscriptions. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## OPTIONS + * + * [--live] + * : Run the command in live mode, updating the subscriptions. + * + * [--fix-memberships] + * : Fix mismatch between active subscriptions and active memberships. + * + * ## EXAMPLES + * + * wp newspack-network fix-subscriptions + */ + public static function fix_subscriptions( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + + $live = isset( $assoc_args['live'] ) ? true : false; + $fix_memberships = isset( $assoc_args['fix-memberships'] ) ? true : false; + + if ( $live ) { + WP_CLI::line( 'Live mode ā€“ data will be updated.' ); + } else { + WP_CLI::line( 'Dry run ā€“ data will not be updated. Use --live flag to run in live mode.' ); + } + WP_CLI::line( '' ); + + global $wpdb; + if ( $fix_memberships ) { + // Query for all subscriptions, regardless of status. + $subscriptions_per_user_results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + "SELECT customer_id, COUNT(customer_id) as count FROM wp_wc_orders WHERE type = 'shop_subscription' GROUP BY customer_id HAVING count > 1" + ); + WP_CLI::line( sprintf( 'Found %d user(s) with multiple subscriptions', count( $subscriptions_per_user_results ) ) ); + } else { + // Query for active subscriptions only. + $subscriptions_per_user_results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + "SELECT customer_id, COUNT(customer_id) as count FROM wp_wc_orders WHERE type = 'shop_subscription' AND status = 'wc-active' GROUP BY customer_id HAVING count > 1" + ); + WP_CLI::line( sprintf( 'Found %d user(s) with multiple active subscriptions', count( $subscriptions_per_user_results ) ) ); + } + WP_CLI::line( '' ); + + foreach ( $subscriptions_per_user_results as $result ) { + $user = get_user_by( 'id', $result->customer_id ); + if ( $user ) { + // Compare to user's memberships. + $active_memberships = \wc_memberships_get_user_memberships( $user->ID, [ 'status' => [ 'active' ] ] ); + if ( $fix_memberships ) { + WP_CLI::line( sprintf( 'User %s (#%d) has %d subscriptions and %d active memberships.', $user->user_email, $result->customer_id, $result->count, count( $active_memberships ) ) ); + } else { + WP_CLI::line( sprintf( 'User %s (#%d) has %d active subscriptions and %d active memberships.', $user->user_email, $result->customer_id, $result->count, count( $active_memberships ) ) ); + } + $memberships_subscriptions_delta = $result->count - count( $active_memberships ); + if ( $memberships_subscriptions_delta > 0 ) { + if ( $fix_memberships ) { + $active_subscription_ids = []; + $subscription_amounts = []; + $user_subscriptions = \wcs_get_users_subscriptions( $user->ID ); + foreach ( $user_subscriptions as $subscription ) { + WP_CLI::line( sprintf( 'Subscription #%d (amount: $%d, started %s) has status "%s"', $subscription->get_id(), $subscription->get_total(), $subscription->get_date( 'start' ), $subscription->get_status() ) ); + $subscription_amounts[] = $subscription->get_total(); + if ( $subscription->get_status() === 'active' ) { + $active_subscription_ids[] = $subscription->get_id(); + } + } + // If all subscription amounts are the same, this might be a mistake. + if ( count( $user_subscriptions ) !== count( array_unique( $subscription_amounts ) ) ) { + WP_CLI::warning( 'Found subscriptions with the same amount, this might be a mistake!' ); + } + + if ( empty( $active_subscription_ids ) && ! empty( $active_memberships ) ) { + WP_CLI::warning( 'No active subscriptions, but has active membership(s)!' ); + } + + if ( empty( $active_memberships ) ) { + WP_CLI::line( sprintf( 'User %s (#%d) has %d subscriptions and no active memberships.', $user->user_email, $result->customer_id, $result->count ) ); + WP_CLI::line( '' ); + continue; + } + + foreach ( $active_memberships as $membership ) { + $membership_subscription = $membership->get_subscription(); + WP_CLI::line( sprintf( 'Membership #%d is tied to subscription #%d', $membership->get_id(), $membership_subscription ? $membership_subscription->get_id() : 0 ) ); + if ( $membership_subscription && ! in_array( $membership_subscription->get_id(), $active_subscription_ids ) ) { + if ( empty( $active_subscription_ids ) ) { + WP_CLI::warning( 'No active subscriptions, cannot fix the membership!' ); + continue; + } + if ( $live ) { + $membership->set_subscription_id( $active_subscription_ids[0] ); + $membership->set_end_date(); // CLear end date so membership is tied to subscription. + $membership->update_status( 'wcm-active' ); + WP_CLI::success( sprintf( 'The subscription (%d) tied to the active membership is not an active subscription. It has been linked to an active subscription (%d).', $membership_subscription->get_id(), $active_subscription_ids[0] ) ); + } else { + WP_CLI::warning( sprintf( 'The subscription (%d) tied to the active membership is not an active subscription.', $membership_subscription->get_id() ) ); + } + } + } + } + } + } else { + WP_CLI::line( sprintf( '%d active subscriptions are assigned to user #%d (no user found)', $result->count, $result->customer_id ) ); + } + WP_CLI::line( '' ); + } + + WP_CLI::line( '' ); + } + + /** + * Reprocess specified events from the event log. + * + * @param array $args Indexed array of args. + * @param array $assoc_args Associative array of args. + * @return void + * + * ## OPTIONS + * + * [--live] + * : Run the command in live mode, updating the subscriptions. + * + * ## EXAMPLES + * + * wp newspack-network reprocess-events 123 42 + */ + public static function reprocess_events( array $args, array $assoc_args ) { + WP_CLI::line( '' ); + if ( ! Site_Role::is_hub() ) { + WP_CLI::error( 'This command can only be run on the Hub site.' ); + } + $event_ids = $args; + if ( empty( $event_ids ) ) { + WP_CLI::error( 'Please provide event IDs to reprocess.' ); + } + + $live = isset( $assoc_args['live'] ) ? true : false; + + global $wpdb; + $table_name = \Newspack_Network\Hub\Database\Event_Log::get_table_name(); + $query = "SELECT * FROM $table_name WHERE id IN (" . implode( ',', $event_ids ) . ')'; + $results = $wpdb->get_results( $query ); //phpcs:ignore + if ( empty( $results ) ) { + WP_CLI::error( 'No events found with the provided IDs.' ); + } + foreach ( $results as $event ) { + $incoming_event_class = 'Newspack_Network\\Incoming_Events\\' . Accepted_Actions::ACTIONS[ $event->action_name ]; + $incoming_event = new $incoming_event_class( false, json_decode( $event->data ), $event->timestamp ); + if ( $live ) { + WP_CLI::line( sprintf( 'Reprocessing event #%d of action: %s.', $event->id, $event->action_name ) ); + $incoming_event->post_process_in_hub(); + } else { + WP_CLI::line( sprintf( 'Would reprocess event #%d of action: %s.', $event->id, $event->action_name ) ); + } + } + WP_CLI::line( '' ); + } +} diff --git a/includes/constants.php b/includes/constants.php index f0bc43b9..b3e96cd7 100644 --- a/includes/constants.php +++ b/includes/constants.php @@ -1,7 +1,7 @@ 'Invalid Signature.', 'INVALID_DATA' => 'Bad request. Invalid Data.', ]; + +const EVENT_LOG_PAGE_SLUG = 'newspack-network-event-log'; diff --git a/includes/hub/admin/class-event-log.php b/includes/hub/admin/class-event-log.php index f7170018..cc68ff7c 100644 --- a/includes/hub/admin/class-event-log.php +++ b/includes/hub/admin/class-event-log.php @@ -8,14 +8,13 @@ namespace Newspack_Network\Hub\Admin; use Newspack_Network\Admin as Network_Admin; +use const Newspack_Network\constants\EVENT_LOG_PAGE_SLUG; /** * Class to handle the Event log admin page */ class Event_Log { - const PAGE_SLUG = 'newspack-network-event-log'; - /** * Runs the initialization. */ @@ -30,7 +29,7 @@ public static function init() { * @return void */ public static function add_admin_menu() { - Network_Admin::add_submenu_page( __( 'Event Log', 'newspack-network' ), self::PAGE_SLUG, [ __CLASS__, 'render_page' ] ); + Network_Admin::add_submenu_page( __( 'Event Log', 'newspack-network' ), EVENT_LOG_PAGE_SLUG, [ __CLASS__, 'render_page' ] ); } /** @@ -39,7 +38,7 @@ public static function add_admin_menu() { * @return void */ public static function admin_enqueue_scripts() { - $page_slug = Network_Admin::PAGE_SLUG . '_page_' . self::PAGE_SLUG; + $page_slug = Network_Admin::PAGE_SLUG . '_page_' . EVENT_LOG_PAGE_SLUG; if ( get_current_screen()->id !== $page_slug ) { return; } @@ -62,7 +61,7 @@ public static function render_page() { echo '

', esc_html( __( 'Event Log', 'newspack-network' ) ), '

'; echo '
'; - echo ''; + echo ''; $table->prepare_items(); diff --git a/includes/hub/admin/class-membership-plans-table.php b/includes/hub/admin/class-membership-plans-table.php index 29770d01..6e3b9280 100644 --- a/includes/hub/admin/class-membership-plans-table.php +++ b/includes/hub/admin/class-membership-plans-table.php @@ -22,16 +22,28 @@ class Membership_Plans_Table extends \WP_List_Table { */ public function get_columns() { $columns = [ - 'id' => __( 'ID', 'newspack-network' ), 'name' => __( 'Name', 'newspack-network' ), ]; $columns['site_url'] = __( 'Site URL', 'newspack-network' ); $columns['network_pass_id'] = __( 'Network ID', 'newspack-network' ); if ( \Newspack_Network\Admin::use_experimental_auditing_features() ) { - $columns['active_members_count'] = __( 'Active Members', 'newspack-network' ); - $columns['network_pass_discrepancies'] = __( 'Discrepancies', 'newspack-network' ); + $columns['active_memberships_count'] = __( 'Active Memberships', 'newspack-network' ); + $columns['network_pass_discrepancies'] = __( 'Membership Discrepancies', 'newspack-network' ); + + $active_subscriptions_sum = array_reduce( + $this->items, + function( $carry, $item ) { + return $carry + ( is_numeric( $item['active_subscriptions_count'] ) ? $item['active_subscriptions_count'] : 0 ); + }, + 0 + ); + $subs_info = sprintf( + ' ', + __( 'Active Subscriptions tied to this membership plan', 'newspack-network' ) + ); + // translators: %d is the sum of active subscriptions. + $columns['active_subscriptions_count'] = sprintf( __( 'Active Subscriptions (%d)', 'newspack-network' ), $active_subscriptions_sum ) . $subs_info; } - $columns['links'] = __( 'Links', 'newspack-network' ); return $columns; } @@ -39,8 +51,25 @@ public function get_columns() { * Prepare items to be displayed */ public function prepare_items() { - $this->_column_headers = [ $this->get_columns(), [], [], 'id' ]; - $this->items = Membership_Plans::get_membershp_plans_from_network(); + $membership_plans_from_network_data = Membership_Plans::get_membership_plans_from_network(); + + // Handle table sorting. + $order = $_REQUEST['order'] ?? false; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $orderby = $_REQUEST['orderby'] ?? false; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( $order && $orderby ) { + usort( + $membership_plans_from_network_data['plans'], + function( $a, $b ) use ( $orderby, $order ) { + if ( $order === 'asc' ) { + return $a[ $orderby ] <=> $b[ $orderby ]; + } + return $b[ $orderby ] <=> $a[ $orderby ]; + } + ); + } + + $this->items = $membership_plans_from_network_data['plans']; + $this->_column_headers = [ $this->get_columns(), [], $this->get_sortable_columns(), 'id' ]; } /** @@ -53,6 +82,10 @@ public function prepare_items() { public function column_default( $item, $column_name ) { $memberships_list_url = sprintf( '%s/wp-admin/edit.php?s&post_status=wcm-active&post_type=wc_user_membership&post_parent=%d', $item['site_url'], $item['id'] ); + if ( $column_name === 'name' ) { + $edit_url = sprintf( '%s/wp-admin/post.php?post=%d&action=edit', $item['site_url'], $item['id'] ); + return sprintf( '%s', esc_url( $edit_url ), $item[ $column_name ] . ' (#' . $item['id'] . ')' ); + } if ( $column_name === 'network_pass_id' && $item[ $column_name ] ) { return sprintf( '%s', $item[ $column_name ] ); } @@ -65,7 +98,15 @@ public function column_default( $item, $column_name ) { $memberships_list_url_with_emails_url = add_query_arg( \Newspack_Network\Woocommerce_Memberships\Admin::MEMBERSHIPS_TABLE_EMAILS_QUERY_PARAM, - implode( ',', $discrepancies ), + implode( + ',', + array_map( + function( $email_address ) { + return urlencode( $email_address ); + }, + $discrepancies + ) + ), $memberships_list_url ); $message = sprintf( @@ -80,13 +121,18 @@ public function column_default( $item, $column_name ) { ); return sprintf( '%s', esc_url( $memberships_list_url_with_emails_url ), esc_html( $message ) ); } - if ( $column_name === 'links' ) { - $edit_url = sprintf( '%s/wp-admin/post.php?post=%d&action=edit', $item['site_url'], $item['id'] ); - return sprintf( '%s', esc_url( $edit_url ), esc_html__( 'Edit', 'newspack-network' ) ); - } - if ( $column_name === 'active_members_count' && $item[ $column_name ] ) { + if ( $column_name === 'active_memberships_count' && isset( $item[ $column_name ] ) ) { return sprintf( '%s', esc_url( $memberships_list_url ), $item[ $column_name ] ); } return isset( $item[ $column_name ] ) ? $item[ $column_name ] : ''; } + + /** + * Get sortable columns. + */ + public function get_sortable_columns() { + return [ + 'network_pass_id' => [ 'network_pass_id', false, __( 'Network Pass ID' ), __( 'Table ordered by Network Pass ID.' ) ], + ]; + } } diff --git a/includes/hub/admin/class-membership-plans.php b/includes/hub/admin/class-membership-plans.php index 6f4bc570..5f1f5007 100644 --- a/includes/hub/admin/class-membership-plans.php +++ b/includes/hub/admin/class-membership-plans.php @@ -55,7 +55,7 @@ public static function render() { @@ -75,16 +75,15 @@ public static function render() { * @param \Newspack_Network\Node\Node $node The node. * @param string $collection_endpoint The collection endpoint. * @param string $collection_endpoint_id The collection endpoint ID. + * @param array $query_args The query args. */ - public static function fetch_collection_from_api( $node, $collection_endpoint, $collection_endpoint_id ) { - $endpoint = sprintf( '%s/wp-json/%s', $node->get_url(), $collection_endpoint ); - if ( Network_Admin::use_experimental_auditing_features() ) { - $endpoint = add_query_arg( 'include_active_members_emails', 1, $endpoint ); - } + public static function fetch_collection_from_api( $node, $collection_endpoint, $collection_endpoint_id, $query_args = [] ) { + $endpoint = add_query_arg( $query_args, sprintf( '%s/wp-json/%s', $node->get_url(), $collection_endpoint ) ); $response = wp_remote_get( // phpcs:ignore $endpoint, [ 'headers' => $node->get_authorization_headers( 'get-woo-' . $collection_endpoint_id ), + 'timeout' => 60, // phpcs:ignore ] ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { @@ -104,27 +103,29 @@ private static function get_membership_plans_from_cache() { /** * Get membership plans from all nodes. */ - public static function get_membershp_plans_from_network() { + public static function get_membership_plans_from_network() { $plans_cache = self::get_membership_plans_from_cache(); if ( $plans_cache && isset( $plans_cache['plans'] ) ) { - return $plans_cache['plans']; + return $plans_cache; } $by_network_pass_id = []; $membership_plans = []; - if ( Network_Admin::use_experimental_auditing_features() ) { - $local_membership_plans = self::get_local_membership_plans(); - foreach ( $local_membership_plans as $local_plan ) { - if ( $local_plan['network_pass_id'] ) { - $by_network_pass_id[ $local_plan['network_pass_id'] ][ $local_plan['site_url'] ] = $local_plan['active_members_emails']; - } + $local_membership_plans = self::get_local_membership_plans(); + foreach ( $local_membership_plans as $local_plan ) { + if ( $local_plan['network_pass_id'] ) { + $by_network_pass_id[ $local_plan['network_pass_id'] ][ $local_plan['site_url'] ] = $local_plan['active_members_emails']; } - $membership_plans = array_merge( $local_membership_plans, $membership_plans ); } + $membership_plans = array_merge( $local_membership_plans, $membership_plans ); $nodes = \Newspack_Network\Hub\Nodes::get_all_nodes(); foreach ( $nodes as $node ) { - $node_plans = self::fetch_collection_from_api( $node, 'wc/v2/memberships/plans', 'membership-plans' ); + $query_args = []; + if ( \Newspack_Network\Admin::use_experimental_auditing_features() ) { + $query_args['include_active_members_emails'] = 1; + } + $node_plans = self::fetch_collection_from_api( $node, 'wc/v2/memberships/plans', 'membership-plans', $query_args ); foreach ( $node_plans as $plan ) { $network_pass_id = null; foreach ( $plan->meta_data as $meta ) { @@ -139,15 +140,17 @@ public static function get_membershp_plans_from_network() { $by_network_pass_id[ $network_pass_id ][ $node->get_url() ] = $plan->active_members_emails; } $membership_plans[] = [ - 'id' => $plan->id, - 'site_url' => $node->get_url(), - 'name' => $plan->name, - 'network_pass_id' => $network_pass_id, - 'active_members_count' => $plan->active_members_count, + 'id' => $plan->id, + 'site_url' => $node->get_url(), + 'name' => $plan->name, + 'network_pass_id' => $network_pass_id, + 'active_memberships_count' => $plan->active_memberships_count, + 'active_subscriptions_count' => $plan->active_subscriptions_count, ]; } } + $discrepancies_emails = []; if ( Network_Admin::use_experimental_auditing_features() ) { $discrepancies = []; foreach ( $by_network_pass_id as $plan_network_pass_id => $by_site ) { @@ -156,6 +159,15 @@ public static function get_membershp_plans_from_network() { $discrepancies[ $plan_network_pass_id ][ $site_url ] = array_diff( $emails, $shared_emails ); } } + + // Get all emails which are discrepant across all sites. + foreach ( $discrepancies as $plan_network_id => $plan_discrepancies ) { + foreach ( $plan_discrepancies as $site_url => $plan_site_discrepancies ) { + $discrepancies_emails = array_merge( $discrepancies_emails, $plan_site_discrepancies ); + } + } + $discrepancies_emails = array_unique( $discrepancies_emails ); + $membership_plans = array_map( function( $plan ) use ( $discrepancies ) { if ( isset( @@ -170,12 +182,13 @@ function( $plan ) use ( $discrepancies ) { $membership_plans ); } - $plans_to_save = [ - 'plans' => $membership_plans, - 'last_updated' => time(), + $memberships_data = [ + 'plans' => $membership_plans, + 'discrepancies_emails' => $discrepancies_emails, + 'last_updated' => time(), ]; - update_option( self::OPTIONS_CACHE_KEY_PLANS, $plans_to_save ); - return $membership_plans; + update_option( self::OPTIONS_CACHE_KEY_PLANS, $memberships_data ); + return $memberships_data; } /** @@ -187,15 +200,21 @@ public static function get_local_membership_plans() { return []; } foreach ( wc_memberships_get_membership_plans() as $plan ) { + $network_pass_id = get_post_meta( $plan->post->ID, \Newspack_Network\Woocommerce_Memberships\Admin::NETWORK_ID_META_KEY, true ); $plan_data = [ - 'id' => $plan->post->ID, - 'site_url' => get_site_url(), - 'name' => $plan->post->post_title, - 'network_pass_id' => get_post_meta( $plan->post->ID, \Newspack_Network\Woocommerce_Memberships\Admin::NETWORK_ID_META_KEY, true ), - 'active_members_count' => $plan->get_memberships_count( 'active' ), + 'id' => $plan->post->ID, + 'site_url' => get_site_url(), + 'name' => $plan->post->post_title, + 'network_pass_id' => $network_pass_id, + 'active_memberships_count' => $plan->get_memberships_count( 'active' ), ]; if ( Network_Admin::use_experimental_auditing_features() ) { $plan_data['active_members_emails'] = \Newspack_Network\Woocommerce_Memberships\Admin::get_active_members_emails( $plan ); + if ( $network_pass_id ) { + $plan_data['active_subscriptions_count'] = \Newspack_Network\Woocommerce_Memberships\Admin::get_plan_related_active_subscriptions( $plan ); + } else { + $plan_data['active_subscriptions_count'] = __( 'Only displayed for plans with a Network ID.', 'newspack-network' ); + } } $membership_plans[] = $plan_data; } diff --git a/includes/hub/admin/class-nodes-list.php b/includes/hub/admin/class-nodes-list.php index d02d43f5..caba2e5e 100644 --- a/includes/hub/admin/class-nodes-list.php +++ b/includes/hub/admin/class-nodes-list.php @@ -27,6 +27,20 @@ public static function init() { add_action( 'admin_bar_menu', [ __CLASS__, 'admin_bar_menu' ], 100 ); } + /** + * Cache for site info responses. + * + * @var array + */ + private static $node_site_info_cache = []; + + /** + * Cache for Hub site info. + * + * @var array + */ + private static $hub_site_info = false; + /** * Modify columns on post type table * @@ -37,16 +51,32 @@ public static function posts_columns( $columns ) { unset( $columns['date'] ); unset( $columns['stats'] ); if ( \Newspack_Network\Admin::use_experimental_auditing_features() ) { + $sync_users_count = \Newspack_Network\Utils\Users::get_synchronized_users_count(); $sync_users_info = sprintf( ' ', sprintf( /* translators: list of user roles which will entail synchronization */ esc_attr__( 'Users with the following roles: %1$s (%2$d on the Hub)', 'newspack-network' ), implode( ', ', \Newspack_Network\Utils\Users::get_synced_user_roles() ), - \Newspack_Network\Utils\Users::get_synchronized_users_count() + $sync_users_count + ) + ); + /* translators: %d is the synchronizable users count. */ + $columns['sync_users'] = sprintf( __( 'Synchronizable Users (%d)', 'newspack-network' ), $sync_users_count ) . $sync_users_info; + if ( isset( $_GET['_newspack_user_discrepancies'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $columns['user_discrepancies'] = __( 'Discrepancies in Sync. Users', 'newspack-network' ); + } + + $not_sync_users_info = sprintf( + ' ', + sprintf( + /* translators: list of user roles which will entail synchronization */ + esc_attr__( 'Users with roles different than the following roles: %1$s (%2$d on the Hub)', 'newspack-network' ), + implode( ', ', \Newspack_Network\Utils\Users::get_synced_user_roles() ), + \Newspack_Network\Utils\Users::get_not_synchronized_users_count() ) ); - $columns['sync_users'] = __( 'Synchronizable Users', 'newspack-network' ) . $sync_users_info; + $columns['not_sync_users'] = __( 'Non-synchronizable Users', 'newspack-network' ) . $not_sync_users_info; } $columns['links'] = __( 'Links', 'newspack-network' ); return $columns; @@ -80,6 +110,38 @@ function ( $bookmark ) {

get_site_info(); + } + $node_site_info = self::$node_site_info_cache[ $post_id ]; + + if ( isset( $_GET['_newspack_user_discrepancies'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! self::$hub_site_info ) { + self::$hub_site_info = [ + 'sync_user_emails' => \Newspack_Network\Utils\Users::get_synchronized_users_emails(), + ]; + } + + // Display user discrepancies. + $node_users_emails = $node_site_info->sync_users_emails ?? []; + // Users who are on the Hub but not on the Node. + $not_on_node = array_diff( self::$hub_site_info['sync_user_emails'], $node_users_emails ); + // Users who are not on the Node but are on the Hub. + $not_on_hub = array_diff( $node_users_emails, self::$hub_site_info['sync_user_emails'] ); + if ( 'user_discrepancies' === $column ) { + ?> + + + + get_url() ) . 'wp-admin/users.php' ); ?> - get_sync_users_count() ); ?> + sync_users_count ?? 0 ); ?> + implode( ',', \Newspack_Network\Utils\Users::get_synced_user_roles() ), + ], + trailingslashit( $node->get_url() ) . 'wp-admin/users.php' + ); + ?> + not_sync_users_count ?? 0 ); ?> \WP_REST_Server::READABLE, + 'callback' => [ __CLASS__, 'api_get_network_subscriptions' ], + 'permission_callback' => '__return_true', + ], + ] + ); + } + + /** + * Get active subscription IDs from the network. + * + * @param string $email Email of the user. + * @param string $plan_network_id Network ID of the plan. + * @param string $site Site URL. + */ + public static function get_active_subscription_ids_from_network( $email, $plan_network_id, $site ) { + $active_subscriptions_ids = []; + foreach ( Nodes::get_all_nodes() as $node ) { + if ( $site === $node->get_url() ) { + // Skip the node which is making the request. It's only interested in the other nodes. + continue; + } + $active_subscriptions_ids = array_merge( + $active_subscriptions_ids, + $node->get_subscriptions_with_network_plan( $email, $plan_network_id ) + ); + } + // Also look on the Hub itself. + $active_subscriptions_ids = array_merge( + $active_subscriptions_ids, + \Newspack_Network\Utils\Users::get_users_active_subscriptions_tied_to_network_id( $email, $plan_network_id ) + ); + return $active_subscriptions_ids; + } + + /** + * Handle the request for active subscriptions tied to a network plan. + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response + */ + public static function api_get_network_subscriptions( $request ) { + $request_error = \Newspack_Network\Utils\Requests::get_request_to_hub_errors( $request ); + if ( \is_wp_error( $request_error ) ) { + return new WP_REST_Response( [ 'error' => $request_error->get_error_message() ], 403 ); + } + + return new WP_REST_Response( + [ + 'active_subscriptions_ids' => self::get_active_subscription_ids_from_network( $request['email'], $request['plan_network_id'], $request['site'] ), + ] + ); + } +} diff --git a/includes/hub/class-node.php b/includes/hub/class-node.php index f50d1867..9c64bd65 100644 --- a/includes/hub/class-node.php +++ b/includes/hub/class-node.php @@ -12,7 +12,7 @@ use WP_Post; /** - * Class to represent one Node of the netowrk + * Class to represent a Node in the network */ class Node { /** @@ -174,21 +174,37 @@ public function get_bookmarks() { /** * Get site info. */ - private function get_site_info() { + public function get_site_info() { $response = wp_remote_get( // phpcs:ignore $this->get_url() . '/wp-json/newspack-network/v1/info', [ 'headers' => $this->get_authorization_headers( 'info' ), + 'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout ] ); return json_decode( wp_remote_retrieve_body( $response ) ); } /** - * Get synchronized users count. + * Get all subscriptions. + * + * @param string $email The email to get subscriptions for. + * @param string $plan_network_id The plan network ID to get subscriptions for. */ - public function get_sync_users_count() { - $site_info = $this->get_site_info(); - return $site_info->sync_users_count ?? 0; + public function get_subscriptions_with_network_plan( $email, $plan_network_id ) { + $response = wp_remote_get( // phpcs:ignore + add_query_arg( + [ + 'email' => $email, + 'plan_network_id' => $plan_network_id, + ], + $this->get_url() . '/wp-json/newspack-network/v1/subscriptions' + ), + [ + 'headers' => $this->get_authorization_headers( 'subscriptions' ), + 'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout + ] + ); + return json_decode( wp_remote_retrieve_body( $response ) ); } } diff --git a/includes/hub/class-pull-endpoint.php b/includes/hub/class-pull-endpoint.php index f6f91d68..cb45b645 100644 --- a/includes/hub/class-pull-endpoint.php +++ b/includes/hub/class-pull-endpoint.php @@ -61,42 +61,22 @@ public static function get_pull_limit() { * @return WP_REST_Response */ public static function handle_pull( $request ) { + $request_error = \Newspack_Network\Utils\Requests::get_request_to_hub_errors( $request ); + if ( \is_wp_error( $request_error ) ) { + return new WP_REST_Response( [ 'error' => $request_error->get_error_message() ], 403 ); + } + $site = $request['site']; $last_processed_id = $request['last_processed_id']; $actions = $request['actions']; - $signature = $request['signature']; - $nonce = $request['nonce']; - Debugger::log( 'Pull request received' ); - Debugger::log( $site ); - Debugger::log( $last_processed_id ); - Debugger::log( $actions ); + Debugger::log( sprintf( 'Pull request received from site %s, with last processed ID %d, for actions: %s.', $site, $last_processed_id, implode( ', ', $actions ) ) ); - if ( empty( $site ) || - empty( $actions ) || - empty( $nonce ) || - empty( $signature ) - ) { + if ( empty( $actions ) ) { return new WP_REST_Response( array( 'error' => 'Bad request.' ), 400 ); } $node = Nodes::get_node_by_url( $site ); - - if ( ! $node ) { - Debugger::log( 'Node not found.' ); - return new WP_REST_Response( array( 'error' => 'Bad request. Site not registered in this Hub.' ), 403 ); - } - - $verified = $node->decrypt_message( $signature, $nonce ); - $verified_message = json_decode( $verified ); - - if ( ! $verified || ! is_object( $verified_message ) || (int) $last_processed_id !== (int) $verified_message->last_processed_id ) { - Debugger::log( 'Signature check failed' ); - return new WP_REST_Response( array( 'error' => 'INVALID_SIGNATURE' ), 403 ); - } - - Debugger::log( 'Successfully verified request' ); - $query_args = [ 'excluded_node_id' => $node->get_id(), 'id_greater_than' => $last_processed_id, diff --git a/includes/hub/database/class-orders.php b/includes/hub/database/class-orders.php index 5cc02b1c..5a073546 100644 --- a/includes/hub/database/class-orders.php +++ b/includes/hub/database/class-orders.php @@ -12,7 +12,7 @@ use Newspack_Network\Debugger; /** - * Class to handle the ubscriptions post type registration + * Class to handle the Orders post type registration */ class Orders { diff --git a/includes/incoming-events/class-reader-registered.php b/includes/incoming-events/class-reader-registered.php index 5f35a17c..89f881ef 100644 --- a/includes/incoming-events/class-reader-registered.php +++ b/includes/incoming-events/class-reader-registered.php @@ -50,6 +50,15 @@ public function maybe_create_user() { User_Update_Watcher::$enabled = false; - $user = User_Utils::get_or_create_user_by_email( $email, $this->get_site(), $this->data->user_id ?? '' ); + // If a user exists, but has a non-synchronizable role, add a synchronizable role. + $existing_user = get_user_by( 'email', $email ); + if ( $existing_user ) { + $synced_roles = \Newspack_Network\Utils\Users::get_synced_user_roles(); + if ( ! array_intersect( $existing_user->roles, $synced_roles ) ) { + $existing_user->add_role( $synced_roles[0] ); + } + } else { + $user = User_Utils::get_or_create_user_by_email( $email, $this->get_site(), $this->data->user_id ?? '', (array) $this->data ); + } } } diff --git a/includes/incoming-events/class-user-deleted.php b/includes/incoming-events/class-user-deleted.php new file mode 100644 index 00000000..b5005807 --- /dev/null +++ b/includes/incoming-events/class-user-deleted.php @@ -0,0 +1,74 @@ +process_user_deleted(); + } + + /** + * Process event in Node + * + * @return void + */ + public function process_in_node() { + $this->process_user_deleted(); + } + + /** + * Process user deleted + * + * @return void + */ + public function process_user_deleted() { + $email = $this->get_email(); + Debugger::log( 'Processing user deletion with email: ' . $email ); + if ( ! $email ) { + return; + } + $user = get_user_by( 'email', $email ); + if ( ! $user ) { + Debugger::log( sprintf( 'User to be deleted not found by email: %s, skipping.', $email ) ); + return; + } + + // Ensure this is a network reader. + $userdata = get_userdata( $user->ID ); + if ( [ NEWSPACK_NETWORK_READER_ROLE ] !== $userdata->roles ) { + Debugger::log( sprintf( 'User %s is not only or not a network reader, skipping deletion.', $email ) ); + return; + } + + /** Make sure `wp_delete_user()` is available. */ + require_once ABSPATH . 'wp-admin/includes/user.php'; + + // Don't broadcast this deletion on the network. + add_filter( 'newspack_network_process_user_deleted', '__return_false' ); + // Delete the user. + $result = \wp_delete_user( $user->ID ); + remove_filter( 'newspack_network_process_user_deleted', '__return_false' ); + + if ( $result ) { + Debugger::log( sprintf( 'User %s deleted.', $email ) ); + } else { + Debugger::log( sprintf( 'User %s could not be deleted.', $email ) ); + } + } +} diff --git a/includes/incoming-events/class-woocommerce-membership-updated.php b/includes/incoming-events/class-woocommerce-membership-updated.php index 1e4c1e5d..2296cf34 100644 --- a/includes/incoming-events/class-woocommerce-membership-updated.php +++ b/includes/incoming-events/class-woocommerce-membership-updated.php @@ -73,6 +73,10 @@ public function update_membership() { User_Update_Watcher::$enabled = false; $user = User_Utils::get_or_create_user_by_email( $email, $this->get_site(), $this->data->user_id ?? '' ); + if ( ! $user ) { + Debugger::log( 'User not found.' ); + return; + } $user_membership = wc_memberships_get_user_membership( $user->ID, $local_plan_id ); @@ -99,16 +103,52 @@ public function update_membership() { return; } - $user_membership->update_status( $this->get_new_status() ); - $user_membership->add_note( - sprintf( - // translators: %s is the site URL. - __( 'Membership status updated via Newspack Network. Status propagated from %s', 'newspack-network' ), - $this->get_site() - ) - ); + $new_status = $this->get_new_status(); + $can_update_membership_status = false; + + // Unless new status is active, perform additional checks. + if ( $new_status !== 'active' ) { + if ( \Newspack_Network\Site_Role::is_hub() ) { + $active_subscriptions_ids = \Newspack_Network\Hub\Network_Data_Endpoint::get_active_subscription_ids_from_network( + $email, + $this->get_plan_network_id(), + get_bloginfo( 'url' ) + ); + $can_update_membership_status = empty( $active_subscriptions_ids ); + } else { + // Check the subscriptions status on the network. The reader might have another subscription on + // a different node that would override the non-active status change here. + $params = [ + 'site' => get_bloginfo( 'url' ), + 'plan_network_id' => $this->get_plan_network_id(), + 'email' => $email, + ]; + $response = \Newspack_Network\Utils\Requests::request_to_hub( 'wp-json/newspack-network/v1/network-subscriptions', $params, 'GET' ); + if ( is_wp_error( $response ) ) { + Debugger::log( 'Error retrieving network subscription data.' ); + } else { + $active_subscriptions_ids = json_decode( wp_remote_retrieve_body( $response ) )->active_subscriptions_ids ?? []; + $can_update_membership_status = empty( $active_subscriptions_ids ); + } + } + } else { + $can_update_membership_status = true; + } + + if ( $can_update_membership_status ) { + $user_membership->update_status( $new_status ); + $user_membership->add_note( + sprintf( + // translators: %s is the site URL. + __( 'Membership status updated via Newspack Network. Status propagated from %s', 'newspack-network' ), + $this->get_site() + ) + ); - Debugger::log( 'User membership updated' ); + Debugger::log( 'User membership updated.' ); + } else { + Debugger::log( 'Did not update user membership.' ); + } } /** diff --git a/includes/node/class-info-endpoints.php b/includes/node/class-info-endpoints.php index b5b57238..ec80aa1d 100644 --- a/includes/node/class-info-endpoints.php +++ b/includes/node/class-info-endpoints.php @@ -38,6 +38,17 @@ public static function register_routes() { ], ] ); + register_rest_route( + 'newspack-network/v1', + '/subscriptions', + [ + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ __CLASS__, 'handle_subscriptions_request' ], + 'permission_callback' => '__return_true', + ], + ] + ); } /** @@ -46,8 +57,24 @@ public static function register_routes() { public static function handle_info_request() { return rest_ensure_response( [ - 'sync_users_count' => \Newspack_Network\Utils\Users::get_synchronized_users_count(), + 'sync_users_count' => \Newspack_Network\Utils\Users::get_synchronized_users_count(), + 'sync_users_emails' => \Newspack_Network\Utils\Users::get_synchronized_users_emails(), + 'not_sync_users_count' => \Newspack_Network\Utils\Users::get_not_synchronized_users_count(), + 'not_sync_users_emails' => \Newspack_Network\Utils\Users::get_not_synchronized_users_emails(), + 'no_role_users_emails' => \Newspack_Network\Utils\Users::get_no_role_users_emails(), ] ); } + + /** + * Handles the subscriptions request. + * Will return the active subscription IDs for the given email, when matching a membership by plan network ID. + * + * @param WP_REST_Request $request Full data about the request. + */ + public static function handle_subscriptions_request( $request ) { + return rest_ensure_response( + \Newspack_Network\Utils\Users::get_users_active_subscriptions_tied_to_network_id( $request['email'], $request['plan_network_id'] ) + ); + } } diff --git a/includes/node/class-pulling.php b/includes/node/class-pulling.php index 9f748624..edbf10ab 100644 --- a/includes/node/class-pulling.php +++ b/includes/node/class-pulling.php @@ -132,49 +132,17 @@ public static function set_last_processed_id( $id ) { } /** - * Gets the request parameters for the pull request + * Makes a request to the Hub to pull data * - * @return array + * @return array|\WP_Error */ - public static function get_request_params() { + public static function make_request() { $params = [ 'last_processed_id' => self::get_last_processed_id(), 'actions' => Accepted_Actions::ACTIONS_THAT_NODES_PULL, 'site' => get_bloginfo( 'url' ), ]; - return self::sign_params( $params ); - } - - /** - * Signs the request parameters with the Node's secret key - * - * @param array $params The request parameters. - * @return array The params array with an additional signature key. - */ - public static function sign_params( $params ) { - $message = wp_json_encode( $params ); - $secret_key = Settings::get_secret_key(); - $nonce = Crypto::generate_nonce(); - $signature = Crypto::encrypt_message( $message, $secret_key, $nonce ); - $params['signature'] = $signature; - $params['nonce'] = $nonce; - return $params; - } - - /** - * Makes a request to the Hub to pull data - * - * @return array|\WP_Error - */ - public static function make_request() { - $url = trailingslashit( Settings::get_hub_url() ) . 'wp-json/newspack-network/v1/pull'; - $params = self::get_request_params(); - $response = wp_remote_post( - $url, - [ - 'body' => $params, - ] - ); + $response = \Newspack_Network\Utils\Requests::request_to_hub( 'wp-json/newspack-network/v1/pull', $params ); if ( is_wp_error( $response ) ) { return $response; } diff --git a/includes/utils/class-requests.php b/includes/utils/class-requests.php new file mode 100644 index 00000000..52b47e33 --- /dev/null +++ b/includes/utils/class-requests.php @@ -0,0 +1,88 @@ + $method, + 'body' => self::sign_params( $params ), + 'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout + ] + ); + } + + /** + * Signs the request parameters with the Node's secret key + * + * @param array $params The request parameters. + * @return array The params array with an additional signature key. + */ + public static function sign_params( $params ) { + $message = wp_json_encode( $params ); + $secret_key = Settings::get_secret_key(); + $nonce = Crypto::generate_nonce(); + $signature = Crypto::encrypt_message( $message, $secret_key, $nonce ); + $params['signature'] = $signature; + $params['nonce'] = $nonce; + return $params; + } + + /** + * Validate a request. + * + * @param WP_REST_Request $request Full data about the request. + * @return bool|WP_Error True if the request is valid, WP_Error otherwise. + */ + public static function get_request_to_hub_errors( $request ) { + $site = $request['site']; + $signature = $request['signature']; + $nonce = $request['nonce']; + + if ( empty( $site ) || + empty( $nonce ) || + empty( $signature ) + ) { + return new WP_Error( 'newspack_network_bad_request', __( 'Bad request.', 'newspack-network' ) ); + } + + $node = \Newspack_Network\Hub\Nodes::get_node_by_url( $site ); + + if ( ! $node ) { + \Newspack_Network\Debugger::log( 'Node not found.' ); + return new WP_Error( 'newspack_network_bad_request_node_not_found', __( 'Bad request. Site not registered in this Hub', 'newspack-network' ) ); + } + + $verified = $node->decrypt_message( $signature, $nonce ); + $verified_message = json_decode( $verified ); + if ( ! $verified || ! is_object( $verified_message ) ) { + \Newspack_Network\Debugger::log( 'Signature check failed' ); + return new WP_Error( 'newspack_network_bad_request_signature', __( 'Bad request. Invalid signature.', 'newspack-network' ) ); + } + + return true; + } +} diff --git a/includes/utils/class-users.php b/includes/utils/class-users.php index cb88ef91..239cec11 100644 --- a/includes/utils/class-users.php +++ b/includes/utils/class-users.php @@ -26,23 +26,36 @@ class Users { * @return WP_User|WP_Error */ public static function get_or_create_user_by_email( $email, $remote_site_url, $remote_id, $insert_array = [] ) { - $existing_user = get_user_by( 'email', $email ); if ( $existing_user ) { + /** + * Fires when fetching an existing network reader account. + * + * @param WP_User $new_user The existing user. + */ + do_action( 'newspack_network_network_reader', $existing_user ); + return $existing_user; } + $nicename = self::generate_user_nicename( $email ); + $user_array = [ - 'user_login' => $email, + 'user_login' => substr( $email, 0, 60 ), 'user_email' => $email, - 'user_nicename' => $email, + 'user_nicename' => $nicename, + 'display_name' => $nicename, 'user_pass' => wp_generate_password(), 'role' => NEWSPACK_NETWORK_READER_ROLE, ]; $user_array = array_merge( $user_array, $insert_array ); + if ( isset( $user_array['meta_input'] ) ) { + $user_array['meta_input'] = (array) $user_array['meta_input']; + } + $user_id = wp_insert_user( $user_array ); if ( is_wp_error( $user_id ) ) { @@ -53,7 +66,39 @@ public static function get_or_create_user_by_email( $email, $remote_site_url, $r update_user_meta( $user_id, self::USER_META_REMOTE_SITE, $remote_site_url ); update_user_meta( $user_id, self::USER_META_REMOTE_ID, $remote_id ); - return get_user_by( 'id', $user_id ); + $new_user = get_user_by( 'id', $user_id ); + + /** + * Fires when a new network reader account is created and all network user meta has been added. + * + * @param WP_User $new_user The newly created user. + */ + do_action( 'newspack_network_new_network_reader', $new_user ); + + return $new_user; + } + + /** + * Generate a URL-sanitized version of the given string for a new reader account. + * + * @param string $name User's display name, or email if not available. + * @return string + */ + public static function generate_user_nicename( $name ) { + $name = self::strip_email_domain( $name ); // If an email address, strip the domain. + + return substr( \sanitize_title( \sanitize_user( $name, true ) ), 0, 50 ); + } + + /** + * Strip the domain part of an email address string. + * If not an email address, just return the string. + * + * @param string $str String to check. + * @return string + */ + public static function strip_email_domain( $str ) { + return trim( explode( '@', $str, 2 )[0] ); } /** @@ -129,13 +174,104 @@ public static function get_synced_user_roles() { * Get synchronized users count. */ public static function get_synchronized_users_count() { - $users = get_users( + return count( self::get_synchronized_users( [ 'id' ] ) ); + } + + /** + * Get synchronized users emails. + */ + public static function get_synchronized_users_emails() { + return array_map( 'strtolower', array_column( self::get_synchronized_users( [ 'user_email' ] ), 'user_email' ) ); + } + + /** + * Get no-role users emails. + */ + public static function get_no_role_users_emails() { + global $wpdb; + $no_role_users_emails = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + "SELECT user_email FROM wp_users WHERE ID IN (SELECT user_id FROM wp_usermeta WHERE meta_key = 'wp_capabilities' AND (meta_value = 'a:0:{}' OR meta_value = '')) OR ID NOT IN (SELECT user_id FROM wp_usermeta WHERE meta_key = 'wp_capabilities')" + ); + return array_map( 'strtolower', array_column( $no_role_users_emails, 'user_email' ) ); + } + + /** + * Get synchronized users. + * + * Note that if a user has duplicate entries for the `wp_capabilities` meta in the `wp_usermeta` table, + * this function will return the user multiple times. + * + * @param array $fields Fields to return. + */ + public static function get_synchronized_users( $fields = [] ) { + return get_users( [ 'role__in' => self::get_synced_user_roles(), - 'fields' => [ 'id' ], + 'fields' => $fields, 'number' => -1, ] ); - return count( $users ); + } + + /** + * Get not synchronized users count. + * + * @param array $fields Fields to return. + */ + public static function get_not_synchronized_users( $fields = [] ) { + return get_users( + [ + 'role__not_in' => self::get_synced_user_roles(), + 'fields' => $fields, + 'number' => -1, + ] + ); + } + + /** + * Get synchronized users emails. + */ + public static function get_not_synchronized_users_emails() { + return array_map( 'strtolower', array_column( self::get_not_synchronized_users( [ 'user_email' ] ), 'user_email' ) ); + } + + /** + * Get not synchronized users count. + */ + public static function get_not_synchronized_users_count() { + return count( self::get_not_synchronized_users( [ 'id' ] ) ); + } + + /** + * Get user's active subscriptions tied to a network ID. + * + * @param string $email The email of the user to look for. + * @param string $plan_network_id The network ID of the plan to look for. + * @return array Array of active subscription IDs. + */ + public static function get_users_active_subscriptions_tied_to_network_id( $email, $plan_network_id ) { + $active_subscription_ids = []; + $user = get_user_by( 'email', $email ); + if ( ! $user ) { + return []; + } + $memberships = wc_memberships_get_user_memberships( $user->ID ); + foreach ( $memberships as $membership ) { + $membership_plan_network_id = get_post_meta( $membership->get_plan()->get_id(), \Newspack_Network\Woocommerce_Memberships\Admin::NETWORK_ID_META_KEY, true ); + if ( $plan_network_id !== $membership_plan_network_id ) { + continue; + } + $wcm_wcs_integration = wc_memberships()->get_integrations_instance()->get_subscriptions_instance(); + $subscription_id = $wcm_wcs_integration ? $wcm_wcs_integration->get_user_membership_subscription_id( $membership->get_id() ) : null; + if ( ! $subscription_id ) { + continue; + } + $subscription = wcs_get_subscription( $subscription_id ); + $subscription_status = $subscription ? $subscription->get_status() : null; + if ( $subscription_status === 'active' ) { + $active_subscription_ids[] = $subscription_id; + } + } + return $active_subscription_ids; } } diff --git a/includes/woocommerce-memberships/class-admin.php b/includes/woocommerce-memberships/class-admin.php index 38a752e9..d9893492 100644 --- a/includes/woocommerce-memberships/class-admin.php +++ b/includes/woocommerce-memberships/class-admin.php @@ -66,6 +66,7 @@ public static function init() { add_filter( 'post_row_actions', array( __CLASS__, 'post_row_actions' ), 99, 2 ); // After the Memberships plugin. add_filter( 'map_meta_cap', array( __CLASS__, 'map_meta_cap' ), 20, 4 ); add_filter( 'wc_memberships_rest_api_membership_plan_data', [ __CLASS__, 'add_data_to_membership_plan_response' ], 2, 3 ); + add_filter( 'woocommerce_rest_prepare_wc_user_membership', [ __CLASS__, 'add_data_to_wc_user_membership_response' ], 2, 3 ); add_filter( 'request', [ __CLASS__, 'request_query' ] ); add_action( 'pre_user_query', [ __CLASS__, 'pre_user_query' ] ); add_action( 'admin_notices', [ __CLASS__, 'admin_notices' ] ); @@ -74,14 +75,18 @@ public static function init() { /** * Get active members' emails. * - * @param \WC_Memberships_Membership_Plan $plan the membership plan. + * @param \WC_Memberships_Membership_Plan $plan The membership plan. */ public static function get_active_members_emails( $plan ) { $active_memberships = $plan->get_memberships( [ 'post_status' => 'wcm-active' ] ); return array_map( function ( $membership ) { $user = get_user_by( 'id', $membership->get_user_id() ); - return $user->user_email; + if ( $user ) { + return strtolower( $user->user_email ); + } else { + return ''; + } }, $active_memberships ); @@ -96,14 +101,63 @@ function ( $membership ) { */ public static function add_data_to_membership_plan_response( $data, $plan, $request ) { if ( $request && isset( $request->get_headers()['x_np_network_signature'] ) ) { - $data['active_members_count'] = $plan->get_memberships_count( 'active' ); - if ( $request->get_param( 'include_active_members_emails' ) ) { - $data['active_members_emails'] = self::get_active_members_emails( $plan ); + $data['active_memberships_count'] = $plan->get_memberships_count( 'active' ); + $network_pass_id = get_post_meta( $plan->id, self::NETWORK_ID_META_KEY, true ); + if ( $network_pass_id && $request->get_param( 'include_active_members_emails' ) ) { + $data['active_subscriptions_count'] = self::get_plan_related_active_subscriptions( $plan ); + $data['active_members_emails'] = array_values( array_unique( self::get_active_members_emails( $plan ) ) ); + } else { + $data['active_subscriptions_count'] = __( 'Only displayed for plans with a Network ID.', 'newspack-network' ); } } return $data; } + /** + * Get the active subscriptions related to a membership plan. + * + * @param \WC_Memberships_Membership_Plan $plan The membership plan. + */ + public static function get_plan_related_active_subscriptions( $plan ) { + $product_ids = $plan->get_product_ids(); + $subscriptions = wcs_get_subscriptions_for_product( $product_ids, 'ids', [ 'subscription_status' => 'active' ] ); + return count( $subscriptions ); + } + + /** + * Filter user membership data from REST API. + * + * @param \WP_REST_Response $response the response object. + * @param null|\WP_Post $user the user membership post object. + * @param \WP_REST_Request $request the request object. + */ + public static function add_data_to_wc_user_membership_response( $response, $user, $request ) { + if ( $request && isset( $request->get_headers()['x_np_network_signature'] ) ) { + // Add network plan ID to the response. + $plan = wc_memberships_get_membership_plan( $response->data['plan_id'] ); + $response->data['plan_network_id'] = get_post_meta( $plan->id, self::NETWORK_ID_META_KEY, true ); + } + return $response; + } + + /** + * Get memberships from the site, with the network plan ID. + * + * @param string $email The email of the user. + */ + public static function get_memberships_by_user_email_with_plan_network_id( $email ) { + if ( ! function_exists( '\wc_memberships_get_user_memberships' ) ) { + return []; + } + $found_memberships = \wc_memberships_get_user_memberships( get_user_by( 'email', $email ) ); + foreach ( $found_memberships as $membership ) { + $membership->customer_id = $membership->get_user_id(); + $membership->start_date = $membership->get_local_start_date( 'Y-m-d\TH:i:s' ); + $membership->plan_network_id = get_post_meta( $membership->plan_id, self::NETWORK_ID_META_KEY, true ); + } + return $found_memberships; + } + /** * Adds a meta box to the membership plan edit screen. */