diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php index 95a5dea1f5..0fa9640911 100644 --- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php +++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-settings.php @@ -19,6 +19,7 @@ class Group_Subscription_Settings { const DEFAULT_SETTINGS = [ 'enabled' => false, 'limit' => 0, + 'name' => '', ]; /** @@ -39,6 +40,26 @@ public static function init() { \add_action( 'add_meta_boxes', [ __CLASS__, 'add_group_subscription_meta_box' ], 26, 2 ); \add_action( 'woocommerce_process_shop_order_meta', [ __CLASS__, 'save_group_subscription_meta' ], 10, 2 ); \add_action( 'wp_ajax_newspack_group_subscription_search_users', [ __CLASS__, 'ajax_search_users' ] ); + + // Customize subscription column in admin list table for group subscriptions. + \add_filter( 'woocommerce_subscription_list_table_column_content', [ __CLASS__, 'filter_subscription_column_content' ], 10, 3 ); + + // Group subscription filter dropdown on subscription list table. + \add_action( 'woocommerce_order_list_table_restrict_manage_orders', [ __CLASS__, 'add_group_subscription_filter' ] ); + \add_action( 'restrict_manage_posts', [ __CLASS__, 'add_group_subscription_filter' ] ); + + // Filter subscription list table query by group status. + \add_filter( 'woocommerce_shop_subscription_list_table_prepare_items_query_args', [ __CLASS__, 'filter_subscriptions_by_group' ] ); + \add_filter( 'request', [ __CLASS__, 'filter_subscriptions_by_group_cpt' ] ); + + // Clear group subscription IDs cache when product group settings change. + \add_action( 'woocommerce_process_product_meta', [ __CLASS__, 'maybe_clear_cache_on_product_save' ] ); + + // Include group name in subscription search. + \add_filter( 'woocommerce_shop_subscription_search_fields', [ __CLASS__, 'add_group_name_search_field' ] ); + \add_filter( 'woocommerce_order_table_search_query_meta_keys', [ __CLASS__, 'add_group_name_hpos_search_field' ] ); + \add_filter( 'posts_join', [ __CLASS__, 'search_group_name_join' ], 10, 2 ); + \add_filter( 'posts_search', [ __CLASS__, 'search_group_name_where' ], 10, 2 ); } /** @@ -139,6 +160,45 @@ public static function add_custom_product_pricing_options( $custom_product_prici return $custom_product_pricing_options; } + /** + * Filter the subscription list table column content to show group name + * and member count for group subscriptions. + * + * @param string $column_content The column content HTML. + * @param \WC_Subscription $subscription The subscription object. + * @param string $column The column name. + * + * @return string The filtered column content. + */ + public static function filter_subscription_column_content( $column_content, $subscription, $column ) { + if ( 'order_title' !== $column ) { + return $column_content; + } + if ( ! Group_Subscription::is_group_subscription( $subscription ) ) { + return $column_content; + } + $settings = self::get_subscription_settings( $subscription ); + $members = Group_Subscription::get_members( $subscription ); + $member_count = count( $members ); + $limit = $settings['limit'] > 0 + ? $settings['limit'] + : __( 'unlimited', 'newspack-plugin' ); + + return sprintf( + '%s (%s)', + \esc_url( $subscription->get_edit_order_url() ), + \esc_html( $settings['name'] ), + \esc_html( + sprintf( + /* translators: 1: member count, 2: member limit or "unlimited" */ + __( '%1$s of %2$s members', 'newspack-plugin' ), + $member_count, + $limit + ) + ) + ); + } + /** * Get the group subscription settings for a product. * @@ -182,9 +242,17 @@ public static function get_subscription_settings( $subscription ) { return self::DEFAULT_SETTINGS; } $product_id = WooCommerce_Subscriptions::get_subscription_product_id( $subscription ); + $owner_name = trim( $subscription->get_formatted_billing_full_name() ); $settings = self::get_product_settings( $product_id ); $settings['enabled'] = $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', true ) ? \wc_string_to_bool( $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', true ) ) : $settings['enabled']; $settings['limit'] = (int) $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'limit', true ) ?: $settings['limit']; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found + $settings['name'] = $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'name', true ) ? + $subscription->get_meta( self::GROUP_SUBSCRIPTION_META_PREFIX . 'name', true ) : + sprintf( + /* translators: %s: The subscription owner's name. */ + __( '%s Group', 'newspack-plugin' ), + $owner_name ? $owner_name . '’s' : __( 'Unnamed', 'newspack-plugin' ) + ); /** * Filter the group subscription settings for a subscription. @@ -225,6 +293,11 @@ public static function update_subscription_settings( $subscription, $settings ) } if ( $should_save ) { $subscription->save(); + + // Clear the cached group subscription IDs if the enabled setting changed. + if ( isset( $settings['enabled'] ) ) { + self::clear_group_subscription_ids_cache(); + } } } @@ -290,6 +363,22 @@ public static function add_group_subscription_options( $subscription ) {

+
+ self::GROUP_SUBSCRIPTION_META_PREFIX . 'name', + 'name' => self::GROUP_SUBSCRIPTION_META_PREFIX . 'name', + 'label' => __( 'Group subscription name', 'newspack-plugin' ), + 'value' => $settings['name'], + 'type' => 'text', + 'wrapper_class' => 'show_if_newspack_group_subscription_enabled', + ] + ) + ); + ?> +
$is_enabled, 'limit' => $limit, + 'name' => $name, + ] + ); + } + + /** + * Add a group subscription filter dropdown to the subscription list table. + * + * @param string $order_type The order type (post type or HPOS order type). + */ + public static function add_group_subscription_filter( $order_type = '' ) { + if ( '' === $order_type ) { + $order_type = isset( $GLOBALS['typenow'] ) ? $GLOBALS['typenow'] : ''; + } + + if ( 'shop_subscription' !== $order_type ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $selected = isset( $_GET['_newspack_group_subscription'] ) ? \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) ) : ''; + + ?> + + postmeta} AS np_group_name ON ( {$wpdb->posts}.ID = np_group_name.post_id AND np_group_name.meta_key = '" . esc_sql( self::GROUP_SUBSCRIPTION_META_PREFIX . 'name' ) . "' ) "; + return $join; + } + + /** + * Extend the search WHERE clause to include group name meta in WP_Query. + * + * @param string $search The search WHERE clause. + * @param \WP_Query $query The WP_Query instance. + * + * @return string The modified search WHERE clause. + */ + public static function search_group_name_where( $search, $query ) { + global $wpdb; + if ( ! self::is_subscription_search_query( $query ) || empty( $search ) ) { + return $search; + } + $term = $query->get( 's' ); + if ( empty( $term ) ) { + return $search; + } + $like = '%' . $wpdb->esc_like( $term ) . '%'; + $or_clause = $wpdb->prepare( ' OR ( np_group_name.meta_value LIKE %s )', $like ); + + // Insert the OR clause inside the existing grouped search condition. + // WP's search clause can end with )) or ) depending on search terms. + if ( preg_match( '/\)\)\s*$/', $search ) ) { + $search = preg_replace( '/\)\)\s*$/', $or_clause . ' ))', $search, 1 ); + } else { + $search = preg_replace( '/\)\s*$/', $or_clause . ' )', $search, 1 ); + } + return $search; + } + + /** + * Check if a WP_Query is a search query for shop_subscription post type. + * + * @param \WP_Query $query The WP_Query instance. + * + * @return bool Whether this is a subscription search query. + */ + private static function is_subscription_search_query( $query ) { + return $query->is_search() && 'shop_subscription' === $query->get( 'post_type' ); + } + + /** + * Transient key for caching group subscription IDs. + */ + const GROUP_SUBSCRIPTION_IDS_TRANSIENT = 'newspack_group_subscription_ids'; + + /** + * Get all subscription IDs that are group subscriptions. + * + * Collects IDs from two sources: + * 1. Subscriptions with the group enabled meta set directly. + * 2. Subscriptions whose product has group subscriptions enabled (inheritance). + * + * Results are cached in a transient for 5 minutes. + * + * @return int[] Array of subscription IDs. + */ + public static function get_group_subscription_ids() { + $cached = \get_transient( self::GROUP_SUBSCRIPTION_IDS_TRANSIENT ); + if ( false !== $cached ) { + return $cached; + } + + $meta_key = self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled'; + + // 1. Subscription IDs with group enabled meta set directly. + // Uses wcs_get_subscriptions which properly handles meta_query in both + // CPT and HPOS modes (via wcs_get_orders_with_meta_query internally). + $enabled_ids = []; + if ( function_exists( 'wcs_get_subscriptions' ) ) { + $enabled_ids = array_keys( + \wcs_get_subscriptions( + [ + 'subscriptions_per_page' => -1, + 'subscription_status' => 'any', + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => $meta_key, + 'value' => 'yes', + ], + ], + ] + ) + ); + } + + // 2. Subscription IDs whose product has group subscriptions enabled. + $product_ids = \get_posts( // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts + [ + 'post_type' => [ 'product', 'product_variation' ], + 'posts_per_page' => -1, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page + 'fields' => 'ids', + 'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ] ); + + $product_sub_ids = []; + if ( ! empty( $product_ids ) && function_exists( 'wcs_get_subscriptions_for_product' ) && function_exists( 'wc_get_product' ) ) { + foreach ( $product_ids as $product_id ) { + $product = \wc_get_product( $product_id ); + if ( ! $product ) { + continue; + } + // Skip variable parent products: variations have their own group settings + // and do not inherit from the parent. + if ( $product->is_type( [ 'variable', 'variable-subscription' ] ) ) { + continue; + } + $product_sub_ids = array_merge( + $product_sub_ids, + array_keys( \wcs_get_subscriptions_for_product( $product_id ) ) + ); + } + } + + // 3. Remove subscriptions that explicitly opted out via an 'enabled = no' override. + // A subscription's own meta takes precedence over product inheritance. + $opted_out_ids = []; + if ( ! empty( $product_sub_ids ) && function_exists( 'wcs_get_subscriptions' ) ) { + $opted_out_ids = array_keys( + \wcs_get_subscriptions( + [ + 'subscriptions_per_page' => -1, + 'subscription_status' => 'any', + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => $meta_key, + 'value' => 'no', + ], + ], + ] + ) + ); + } + + $product_sub_ids = array_diff( $product_sub_ids, $opted_out_ids ); + + $result = array_values( array_unique( array_merge( $enabled_ids, $product_sub_ids ) ) ); + + \set_transient( self::GROUP_SUBSCRIPTION_IDS_TRANSIENT, $result, 5 * MINUTE_IN_SECONDS ); + + return $result; + } + + /** + * Clear the group subscription IDs transient cache. + * + * Called when group subscription settings change to ensure the filter + * reflects current state. + */ + public static function clear_group_subscription_ids_cache() { + \delete_transient( self::GROUP_SUBSCRIPTION_IDS_TRANSIENT ); + } + + /** + * Clear the group subscription IDs cache when a product's group settings + * may have changed. + * + * @param int $product_id The product ID being saved. + */ + public static function maybe_clear_cache_on_product_save( $product_id ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( isset( $_POST[ self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled' ] ) || \get_post_meta( $product_id, self::GROUP_SUBSCRIPTION_META_PREFIX . 'enabled', true ) ) { + self::clear_group_subscription_ids_cache(); + } + } + + /** + * Filter the subscription list table query by group subscription status (HPOS). + * + * @param array $query_args The query args for the list table. + * + * @return array The filtered query args. + */ + public static function filter_subscriptions_by_group( $query_args ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $_GET['_newspack_group_subscription'] ) ) { + return $query_args; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $filter = \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) ); + if ( ! in_array( $filter, [ 'group', 'non-group' ], true ) ) { + return $query_args; + } + + $group_ids = self::get_group_subscription_ids(); + + if ( 'group' === $filter ) { + if ( empty( $group_ids ) ) { + $query_args['post__in'] = [ 0 ]; + } elseif ( ! isset( $query_args['post__in'] ) ) { + $query_args['post__in'] = $group_ids; + } else { + $intersected = array_intersect( $query_args['post__in'], $group_ids ); + $query_args['post__in'] = empty( $intersected ) ? [ 0 ] : array_values( $intersected ); + } + } elseif ( 'non-group' === $filter ) { + if ( ! empty( $group_ids ) ) { + $query_args['post__not_in'] = isset( $query_args['post__not_in'] ) // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in + ? array_merge( $query_args['post__not_in'], $group_ids ) + : $group_ids; + } + } + + return $query_args; + } + + /** + * Filter the subscription list by group subscription status (CPT mode). + * + * Uses the 'request' filter, which is the same approach WCS uses for its + * own product/customer/payment method filters on edit.php. + * + * @param array $query_vars The query vars for the admin list table request. + * + * @return array The filtered query vars. + */ + public static function filter_subscriptions_by_group_cpt( $query_vars ) { + global $typenow; + + if ( ! is_admin() || 'shop_subscription' !== $typenow ) { + return $query_vars; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $_GET['_newspack_group_subscription'] ) ) { + return $query_vars; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $filter = \sanitize_text_field( \wp_unslash( $_GET['_newspack_group_subscription'] ) ); + if ( ! in_array( $filter, [ 'group', 'non-group' ], true ) ) { + return $query_vars; + } + + $group_ids = self::get_group_subscription_ids(); + + if ( 'group' === $filter ) { + if ( empty( $group_ids ) ) { + $query_vars['post__in'] = [ 0 ]; + } elseif ( ! isset( $query_vars['post__in'] ) ) { + $query_vars['post__in'] = $group_ids; + } else { + $intersected = array_intersect( $query_vars['post__in'], $group_ids ); + $query_vars['post__in'] = empty( $intersected ) ? [ 0 ] : array_values( $intersected ); + } + } elseif ( 'non-group' === $filter ) { + if ( ! empty( $group_ids ) ) { + $query_vars['post__not_in'] = isset( $query_vars['post__not_in'] ) // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in + ? array_merge( $query_vars['post__not_in'], $group_ids ) + : $group_ids; + } + } + + return $query_vars; } } Group_Subscription_Settings::init(); diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index 254685bd25..8ff66441a2 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -343,6 +343,12 @@ public function update_dates( $dates ) { $this->data['dates'][ $type ] = $date; } } + public function get_formatted_billing_full_name() { + $first = $this->data['billing_first_name'] ?? ''; + $last = $this->data['billing_last_name'] ?? ''; + $name = trim( "$first $last" ); + return $name ? $name : ''; + } public function get_items() { return $this->data['items'] ?? []; } diff --git a/tests/unit-tests/content-gate/group-subscriptions.php b/tests/unit-tests/content-gate/group-subscriptions.php index a4570797c9..3ecb7e152d 100644 --- a/tests/unit-tests/content-gate/group-subscriptions.php +++ b/tests/unit-tests/content-gate/group-subscriptions.php @@ -49,7 +49,7 @@ public function tear_down() { * * @param int $customer_id Customer/owner user ID. * @param array $settings Optional group-subscription settings to apply. - * Supported keys: 'enabled' (bool), 'limit' (int). + * Supported keys: 'enabled' (bool), 'limit' (int), 'name' (string). * @return \WC_Subscription */ private function create_group_subscription( $customer_id, $settings = [] ) { @@ -57,6 +57,7 @@ private function create_group_subscription( $customer_id, $settings = [] ) { [ 'enabled' => true, 'limit' => 0, + 'name' => '', ], $settings ); @@ -75,6 +76,9 @@ private function create_group_subscription( $customer_id, $settings = [] ) { if ( $settings['limit'] > 0 ) { $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'limit', $settings['limit'] ); } + if ( ! empty( $settings['name'] ) ) { + $sub->update_meta_data( Group_Subscription_Settings::GROUP_SUBSCRIPTION_META_PREFIX . 'name', $settings['name'] ); + } return $sub; } @@ -425,6 +429,101 @@ public function test_get_group_subscriptions_for_user_with_no_memberships() { $this->assertEmpty( $result ); } + // ------------------------------------------------------------------------- + // Group_Subscription_Settings name tests + // ------------------------------------------------------------------------- + + /** + * Test get_subscription_settings() returns a default name based on the + * subscription owner's billing name when no custom name is set. + */ + public function test_group_name_defaults_to_owner_name() { + $owner_id = $this->create_reader_user(); + $group_sub = $this->create_group_subscription( $owner_id ); + + // Set billing name on the subscription so get_formatted_billing_full_name() returns a real name. + $group_sub->data['billing_first_name'] = 'Jane'; + $group_sub->data['billing_last_name'] = 'Doe'; + + $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub ); + + $this->assertNotEmpty( $settings['name'], 'Default name should not be empty' ); + $this->assertStringContainsString( + 'Jane', + $settings['name'], + 'Default name should contain the owner first name' + ); + $this->assertStringContainsString( + "\u{2019}s Group", + $settings['name'], + 'Default name should end with the possessive Group suffix' + ); + } + + /** + * Test get_subscription_settings() returns a custom name when one is stored + * in subscription meta. + */ + public function test_group_name_custom_override() { + $owner_id = $this->create_reader_user(); + $group_sub = $this->create_group_subscription( $owner_id, [ 'name' => 'Acme Newsroom' ] ); + + $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub ); + + $this->assertEquals( 'Acme Newsroom', $settings['name'] ); + } + + /** + * Test update_subscription_settings() persists a name change that is + * reflected in subsequent get_subscription_settings() calls. + */ + public function test_group_name_update_and_read_back() { + $owner_id = $this->create_reader_user(); + $group_sub = $this->create_group_subscription( $owner_id ); + + Group_Subscription_Settings::update_subscription_settings( + $group_sub, + [ 'name' => 'Daily Planet' ] + ); + + $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub ); + + $this->assertEquals( 'Daily Planet', $settings['name'] ); + } + + /** + * Test that an empty name in update_subscription_settings() stores an + * empty string, which causes get_subscription_settings() to fall back + * to the default owner-based name. + */ + public function test_group_name_empty_falls_back_to_default() { + $owner_id = $this->create_reader_user(); + $group_sub = $this->create_group_subscription( $owner_id, [ 'name' => 'Temp Name' ] ); + + // Set billing name on the subscription so the fallback name is based on the actual owner name. + $group_sub->data['billing_first_name'] = 'Jane'; + $group_sub->data['billing_last_name'] = 'Doe'; + + Group_Subscription_Settings::update_subscription_settings( + $group_sub, + [ 'name' => '' ] + ); + + $settings = Group_Subscription_Settings::get_subscription_settings( $group_sub ); + + // After clearing the custom name, it should revert to the default. + $this->assertStringContainsString( + 'Jane', + $settings['name'], + 'Clearing the name should revert to the default owner-based name' + ); + $this->assertStringContainsString( + "\u{2019}s Group", + $settings['name'], + 'Default name should end with the possessive Group suffix' + ); + } + // ------------------------------------------------------------------------- // Group_Subscription_Invite tests // -------------------------------------------------------------------------