diff --git a/includes/class-rest-authenticaton.php b/includes/class-rest-authenticaton.php index f16148f3..269c3ec7 100644 --- a/includes/class-rest-authenticaton.php +++ b/includes/class-rest-authenticaton.php @@ -33,6 +33,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/cli/backfillers/class-reader-registered.php b/includes/cli/backfillers/class-reader-registered.php index b801e7d7..4e291d30 100644 --- a/includes/cli/backfillers/class-reader-registered.php +++ b/includes/cli/backfillers/class-reader-registered.php @@ -56,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 ) ); diff --git a/includes/cli/backfillers/class-woocommerce-membership-updated.php b/includes/cli/backfillers/class-woocommerce-membership-updated.php index 6a30c52f..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,6 +80,7 @@ public function get_events() { 'membership_id' => $membership->get_id(), 'new_status' => $status, ]; + $timestamp = null; switch ( $status ) { case 'paused': $timestamp = strtotime( $membership->get_paused_date() ); diff --git a/includes/hub/admin/class-membership-plans-table.php b/includes/hub/admin/class-membership-plans-table.php index 29770d01..f2293cfe 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 = isset( $_REQUEST['order'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended + $orderby = isset( $_REQUEST['orderby'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended + 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..da252be3 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,21 +75,20 @@ 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 ) ) { Debugger::log( 'API request for node\'s memberships failed' ); - return; + return null; } return json_decode( wp_remote_retrieve_body( $response ) ); } @@ -104,27 +103,32 @@ 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 ); + if ( $node_plans === null ) { + continue; + } foreach ( $node_plans as $plan ) { $network_pass_id = null; foreach ( $plan->meta_data as $meta ) { @@ -139,15 +143,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 +162,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 +185,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 +203,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/woocommerce-memberships/class-admin.php b/includes/woocommerce-memberships/class-admin.php index 29178114..4ea5cf16 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' ] ); @@ -100,14 +101,47 @@ 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'] ); + if ( $plan !== false ) { + $response->data['plan_network_id'] = get_post_meta( $plan->id, self::NETWORK_ID_META_KEY, true ); + } + } + return $response; + } + /** * Adds a meta box to the membership plan edit screen. */