Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.77% covered (danger)
48.77%
79 / 162
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Products
48.77% covered (danger)
48.77%
79 / 162
57.14% covered (warning)
57.14%
8 / 14
221.12
0.00% covered (danger)
0.00%
0 / 1
 get_products_classes
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 register_product_endpoints
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_not_shown_products
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_products
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get_products_api_data
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get_products_by_ownership
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 get_all_plugin_filenames
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 get_product
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_product_class
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_products_slugs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_product_data_schema
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
2
 extend_plugins_action_links
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 get_interstitials_state
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 update_interstitials_state
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Class for manipulating products
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10/**
11 * A class for everything related to product handling in My Jetpack
12 */
13class Products {
14    /**
15     * Constants for the status of a product on a site
16     *
17     * @var string
18     */
19    public const STATUS_SITE_CONNECTION_ERROR       = 'site_connection_error';
20    public const STATUS_USER_CONNECTION_ERROR       = 'user_connection_error';
21    public const STATUS_ACTIVE                      = 'active';
22    public const STATUS_CAN_UPGRADE                 = 'can_upgrade';
23    public const STATUS_EXPIRING_SOON               = 'expiring';
24    public const STATUS_EXPIRED                     = 'expired';
25    public const STATUS_INACTIVE                    = 'inactive';
26    public const STATUS_MODULE_DISABLED             = 'module_disabled';
27    public const STATUS_PLUGIN_ABSENT               = 'plugin_absent';
28    public const STATUS_PLUGIN_ABSENT_WITH_PLAN     = 'plugin_absent_with_plan';
29    public const STATUS_NEEDS_PLAN                  = 'needs_plan';
30    public const STATUS_NEEDS_ACTIVATION            = 'needs_activation';
31    public const STATUS_NEEDS_FIRST_SITE_CONNECTION = 'needs_first_site_connection';
32    public const STATUS_NEEDS_ATTENTION__WARNING    = 'needs_attention_warning';
33    public const STATUS_NEEDS_ATTENTION__ERROR      = 'needs_attention_error';
34
35    public const INTERSTITIALS_OPTION_NAME = 'my_jetpack_products_interstitials_state';
36
37    /**
38     * List of statuses that display the module as disabled
39     * This is defined as the statuses in which the user willingly has the module disabled whether it be by
40     * default, uninstalling the plugin, disabling the module, or not renewing their plan.
41     *
42     * @var array
43     */
44    public static $disabled_module_statuses = array(
45        self::STATUS_INACTIVE,
46        self::STATUS_MODULE_DISABLED,
47        self::STATUS_PLUGIN_ABSENT,
48        self::STATUS_PLUGIN_ABSENT_WITH_PLAN,
49        self::STATUS_NEEDS_ACTIVATION,
50        self::STATUS_NEEDS_FIRST_SITE_CONNECTION,
51    );
52
53    /**
54     * List of statuses that display the module as broken
55     *
56     * @var array
57     */
58    public static $broken_module_statuses = array(
59        self::STATUS_SITE_CONNECTION_ERROR,
60        self::STATUS_USER_CONNECTION_ERROR,
61    );
62
63    /**
64     * List of statuses that display the module as needing attention with a warning
65     *
66     * @var array
67     */
68    public static $warning_module_statuses = array(
69        self::STATUS_SITE_CONNECTION_ERROR,
70        self::STATUS_USER_CONNECTION_ERROR,
71        self::STATUS_PLUGIN_ABSENT_WITH_PLAN,
72        self::STATUS_NEEDS_PLAN,
73        self::STATUS_NEEDS_ATTENTION__ERROR,
74        self::STATUS_NEEDS_ATTENTION__WARNING,
75    );
76
77    /**
78     * List of statuses that display the module as active
79     *
80     * @var array
81     */
82    public static $active_module_statuses = array(
83        self::STATUS_ACTIVE,
84        self::STATUS_CAN_UPGRADE,
85    );
86
87    /**
88     * List of statuses that display the module as active
89     *
90     * @var array
91     */
92    public static $expiring_or_expired_module_statuses = array(
93        self::STATUS_EXPIRING_SOON,
94        self::STATUS_EXPIRED,
95    );
96
97    /**
98     * List of all statuses that a product can have
99     *
100     * @var array
101     */
102    public static $all_statuses = array(
103        self::STATUS_SITE_CONNECTION_ERROR,
104        self::STATUS_USER_CONNECTION_ERROR,
105        self::STATUS_ACTIVE,
106        self::STATUS_CAN_UPGRADE,
107        self::STATUS_EXPIRING_SOON,
108        self::STATUS_EXPIRED,
109        self::STATUS_INACTIVE,
110        self::STATUS_MODULE_DISABLED,
111        self::STATUS_PLUGIN_ABSENT,
112        self::STATUS_PLUGIN_ABSENT_WITH_PLAN,
113        self::STATUS_NEEDS_PLAN,
114        self::STATUS_NEEDS_ACTIVATION,
115        self::STATUS_NEEDS_FIRST_SITE_CONNECTION,
116        self::STATUS_NEEDS_ATTENTION__WARNING,
117        self::STATUS_NEEDS_ATTENTION__ERROR,
118    );
119
120    /**
121     * Get the list of Products classes
122     *
123     * Here's where all the existing Products are registered
124     *
125     * @throws \Exception If the result of a filter has invalid classes.
126     * @return array List of class names
127     */
128    public static function get_products_classes() {
129        $classes = array(
130            'anti-spam'        => Products\Anti_Spam::class,
131            'backup'           => Products\Backup::class,
132            'boost'            => Products\Boost::class,
133            'crm'              => Products\Crm::class,
134            'creator'          => Products\Creator::class,
135            'extras'           => Products\Extras::class,
136            'jetpack-ai'       => Products\Jetpack_Ai::class,
137            // TODO: Remove this duplicate class ('ai')? See: https://github.com/Automattic/jetpack/pull/35910#pullrequestreview-2456462227.
138            'ai'               => Products\Jetpack_Ai::class,
139            'scan'             => Products\Scan::class,
140            'search'           => Products\Search::class,
141            'social'           => Products\Social::class,
142            'security'         => Products\Security::class,
143            'protect'          => Products\Protect::class,
144            'videopress'       => Products\Videopress::class,
145            'stats'            => Products\Stats::class,
146            'growth'           => Products\Growth::class,
147            'complete'         => Products\Complete::class,
148            'pro'              => Products\Pro::class,
149            // Features.
150            'activity-log'     => Products\Activity_Log::class,
151            'newsletter'       => Products\Newsletter::class,
152            'site-accelerator' => Products\Site_Accelerator::class,
153            'related-posts'    => Products\Related_Posts::class,
154            'jetpack-forms'    => Products\Jetpack_Forms::class,
155        );
156
157        /**
158         * This filter allows plugin to override the Product class of a given product. The new class must be a child class of the default one declared in My Jetpack
159         *
160         * For example, a stand-alone plugin could overwrite its product class to control specific behavior of the product in the My Jetpack page after it is active without having to commit changes to the My Jetpack package:
161         *
162         * add_filter( 'my_jetpack_products_classes', function( $classes ) {
163         *  $classes['my_plugin'] = 'My_Plugin'; // a class that extends the original one declared in the My Jetpack package.
164         *  return $classes
165         * } );
166         *
167         * @param array $classes An array where the keys are the product slugs and the values are the class names.
168         */
169        $final_classes = apply_filters( 'my_jetpack_products_classes', $classes );
170
171        // Check that the classes are still child of the same original classes.
172        foreach ( (array) $final_classes as $slug => $final_class ) {
173            if ( $final_class === $classes[ $slug ] ) {
174                continue;
175            }
176            if ( ! class_exists( $final_class ) || ! is_subclass_of( $final_class, $classes[ $slug ] ) ) {
177                throw new \Exception( 'You can only overwrite a Product class with a child of the original class.' );
178            }
179        }
180
181        return $final_classes;
182    }
183
184    /**
185     * Register endpoints related to product classes
186     *
187     * @return void
188     */
189    public static function register_product_endpoints() {
190        $classes = self::get_products_classes();
191
192        foreach ( $classes as $class ) {
193            $class::register_endpoints();
194        }
195    }
196
197    /**
198     * List of product slugs that are displayed on the main My Jetpack page
199     *
200     * @var array
201     */
202    public static $shown_products = array(
203        'anti-spam',
204        'backup',
205        'boost',
206        'crm',
207        'jetpack-ai',
208        'search',
209        'social',
210        'protect',
211        'videopress',
212        'stats',
213    );
214
215    /**
216     * Gets the list of product slugs that are Not displayed on the main My Jetpack page
217     *
218     * @return array
219     */
220    public static function get_not_shown_products() {
221        return array_diff( array_keys( static::get_products_classes() ), self::$shown_products );
222    }
223
224    /**
225     * Product data
226     *
227     * @param array $product_slugs (optional) An array of specified product slugs.
228     * @return array Jetpack products on the site and their availability.
229     */
230    public static function get_products( $product_slugs = array() ) {
231        $all_classes = self::get_products_classes();
232        $products    = array();
233        // If an array of $product_slugs are passed, return only the products specified in $product_slugs array.
234        if ( $product_slugs ) {
235            foreach ( $product_slugs as $product_slug ) {
236                if ( isset( $all_classes[ $product_slug ] ) ) {
237                    $class                     = $all_classes[ $product_slug ];
238                    $products[ $product_slug ] = $class::get_info();
239                }
240            }
241
242            return $products;
243        }
244        // Otherwise return All products.
245        foreach ( $all_classes as $slug => $class ) {
246            $products[ $slug ] = $class::get_info();
247        }
248
249        return $products;
250    }
251
252    /**
253     * Get products data related to the wpcom api
254     *
255     * @param array $product_slugs - (optional) An array of specified product slugs.
256     * @return array
257     */
258    public static function get_products_api_data( $product_slugs = array() ) {
259        $all_classes = self::get_products_classes();
260        $products    = array();
261        // If an array of $product_slugs are passed, return only the products specified in $product_slugs array.
262        if ( $product_slugs ) {
263            foreach ( $product_slugs as $product_slug ) {
264                if ( isset( $all_classes[ $product_slug ] ) ) {
265                    $class                     = $all_classes[ $product_slug ];
266                    $products[ $product_slug ] = $class::get_wpcom_info();
267                }
268            }
269
270            return $products;
271        }
272        // Otherwise return All products.
273        foreach ( $all_classes as $slug => $class ) {
274            $products[ $slug ] = $class::get_wpcom_info();
275        }
276
277        return $products;
278    }
279
280    /**
281     * Get a list of products sorted by whether or not the user owns them
282     * An owned product is defined as a product that is any of the following
283     * - Active
284     * - Has historically been active
285     * - The user has a plan that includes the product
286     * - The user has the standalone plugin for the product installed
287     *
288     * @param string $type The type of ownership to return ('owned' or 'unowned').
289     *
290     * @return array
291     */
292    public static function get_products_by_ownership( $type ) {
293        $owned_active_products   = array();
294        $owned_warning_products  = array();
295        $owned_inactive_products = array();
296        $unowned_products        = array();
297
298        foreach ( self::get_products_classes() as $class ) {
299            $product_slug = $class::$slug;
300            $status       = $class::get_status();
301
302            if ( $class::is_owned() ) {
303                // This sorts the the products in the order of active -> warning -> inactive.
304                // This enables the frontend to display them in that order.
305                // This is not needed for unowned products as those will always have a status of 'inactive'.
306                if ( in_array( $status, self::$active_module_statuses, true ) ) {
307                    array_push( $owned_active_products, $product_slug );
308                } elseif ( in_array( $status, self::$warning_module_statuses, true ) ) {
309                    array_push( $owned_warning_products, $product_slug );
310                } else {
311                    array_push( $owned_inactive_products, $product_slug );
312                }
313                continue;
314            }
315
316            array_push( $unowned_products, $product_slug );
317        }
318
319        $data = array(
320            'owned'   => array_values(
321                array_unique(
322                    array_merge(
323                        $owned_active_products,
324                        $owned_warning_products,
325                        $owned_inactive_products
326                    )
327                )
328            ),
329            'unowned' => array_values(
330                array_unique( $unowned_products )
331            ),
332        );
333
334        return $data[ $type ];
335    }
336
337    /**
338     * Get all plugin filenames associated with the products.
339     *
340     * @return array
341     */
342    public static function get_all_plugin_filenames() {
343        $filenames = array();
344        foreach ( self::get_products_classes() as $class ) {
345            if ( ! isset( $class::$plugin_filename ) ) {
346                continue;
347            }
348
349            if ( is_array( $class::$plugin_filename ) ) {
350                $filenames = array_merge( $filenames, $class::$plugin_filename );
351            } else {
352                $filenames[] = $class::$plugin_filename;
353            }
354        }
355        return $filenames;
356    }
357
358    /**
359     * Get one product data by its slug
360     *
361     * @param string $product_slug The product slug.
362     *
363     * @return ?array
364     */
365    public static function get_product( $product_slug ) {
366        $classes = self::get_products_classes();
367        if ( isset( $classes[ $product_slug ] ) ) {
368            return $classes[ $product_slug ]::get_info();
369        }
370    }
371
372    /**
373     * Get one product Class name
374     *
375     * @param string $product_slug The product slug.
376     *
377     * @return ?string
378     */
379    public static function get_product_class( $product_slug ) {
380        $classes = self::get_products_classes();
381        if ( isset( $classes[ $product_slug ] ) ) {
382            return $classes[ $product_slug ];
383        }
384    }
385
386    /**
387     * Return product slugs list.
388     *
389     * @return array Product slugs array.
390     */
391    public static function get_products_slugs() {
392        return array_keys( self::get_products_classes() );
393    }
394
395    /**
396     * Gets the json schema for the product data
397     *
398     * @return array
399     */
400    public static function get_product_data_schema() {
401        return array(
402            'title'      => 'The requested product data',
403            'type'       => 'object',
404            'properties' => array(
405                'product'     => array(
406                    'description'       => __( 'Product slug', 'jetpack-my-jetpack' ),
407                    'type'              => 'string',
408                    'enum'              => __CLASS__ . '::get_product_slugs',
409                    'required'          => false,
410                    'validate_callback' => __CLASS__ . '::check_product_argument',
411                ),
412                'action'      => array(
413                    'description'       => __( 'Production action to execute', 'jetpack-my-jetpack' ),
414                    'type'              => 'string',
415                    'enum'              => array( 'activate', 'deactivate' ),
416                    'required'          => false,
417                    'validate_callback' => __CLASS__ . '::check_product_argument',
418                ),
419                'slug'        => array(
420                    'title' => 'The product slug',
421                    'type'  => 'string',
422                ),
423                'name'        => array(
424                    'title' => 'The product name',
425                    'type'  => 'string',
426                ),
427                'description' => array(
428                    'title' => 'The product description',
429                    'type'  => 'string',
430                ),
431                'status'      => array(
432                    'title' => 'The product status',
433                    'type'  => 'string',
434                    'enum'  => self::$all_statuses,
435                ),
436                'class'       => array(
437                    'title' => 'The product class handler',
438                    'type'  => 'string',
439                ),
440            ),
441        );
442    }
443
444    /**
445     * Extend actions links for plugins
446     * tied to the Products.
447     */
448    public static function extend_plugins_action_links() {
449        $products = array(
450            'backup',
451            'boost',
452            'crm',
453            'videopress',
454            'social',
455            'protect',
456            'crm',
457            'search',
458            'jetpack-ai',
459        );
460
461        // Add plugin action links for the core Jetpack plugin.
462        Product::extend_core_plugin_action_links();
463
464        // Add plugin action links to standalone products.
465        foreach ( $products as $product ) {
466            $class_name = self::get_product_class( $product );
467            $class_name::extend_plugin_action_links();
468        }
469    }
470
471    /**
472     * Get interstitials state for the products
473     *
474     * @return array A key-value array of product slugs and their interstitial states. True means the interstitial was seen by the user for that product.
475     */
476    public static function get_interstitials_state() {
477        return get_option( self::INTERSTITIALS_OPTION_NAME, array() );
478    }
479
480    /**
481     * Update interstitials state for the products
482     *
483     * @param array $new_state A key-value array of product slugs and their interstitial states.
484     *
485     * @return bool True if the option was updated successfully, false otherwise.
486     */
487    public static function update_interstitials_state( $new_state ) {
488
489        // Merge the existing interstitials state with the new state.
490        $interstitials_state = array_merge( self::get_interstitials_state(), $new_state );
491
492        return update_option( self::INTERSTITIALS_OPTION_NAME, $interstitials_state );
493    }
494}