Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.34% covered (warning)
75.34%
55 / 73
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Notices
75.34% covered (warning)
75.34%
55 / 73
66.67% covered (warning)
66.67%
4 / 6
37.93
0.00% covered (danger)
0.00%
0 / 1
 update_notice
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 get_notices_to_show
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 get_notices_from_wpcom
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 is_notice_hidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_hidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 request_as_blog
39.13% covered (danger)
39.13%
9 / 23
0.00% covered (danger)
0.00%
0 / 1
22.43
1<?php
2/**
3 * Computes the dashboard notices, merging WPCOM-stored dismissals with locally-derived flags.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Stats\Options as Stats_Options;
12use Jetpack_Options;
13use WP_Error;
14
15/**
16 * Ported from `Automattic\Jetpack\Stats_Admin\Notices` so premium-analytics owns the notices
17 * surface without depending on the (to-be-deprecated) stats-admin package. The WPCOM dismissal
18 * state is fetched/updated through the blog token; the opt-in/opt-out/feedback/GDPR flags are
19 * derived locally from `Stats\Options` and the environment.
20 */
21class Notices {
22    const NOTICES_CACHE_KEY             = 'jetpack_premium_analytics_notices_cache_key';
23    const OPT_OUT_NEW_STATS_NOTICE_ID   = 'opt_out_new_stats';
24    const NEW_STATS_FEEDBACK_NOTICE_ID  = 'new_stats_feedback';
25    const OPT_IN_NEW_STATS_NOTICE_ID    = 'opt_in_new_stats';
26    const GDPR_COOKIE_CONSENT_NOTICE_ID = 'gdpr_cookie_consent';
27
28    const VIEWS_TO_SHOW_FEEDBACK      = 3;
29    const POSTPONE_OPT_IN_NOTICE_DAYS = 30;
30
31    /**
32     * How long the WPCOM dismissal state stays cached. While stats-admin runs in parallel, both
33     * surfaces read the same WPCOM resource under separate cache keys, so a dismissal through one
34     * can leave the other stale for up to this window — acceptable during the migration.
35     *
36     * @var int
37     */
38    const CACHE_TTL = 5 * MINUTE_IN_SECONDS;
39
40    /**
41     * Update notice status.
42     *
43     * @param mixed $id ID of the notice.
44     * @param mixed $status Status of the notice.
45     * @param int   $postponed_for Postponed for how many seconds.
46     * @return array|WP_Error
47     */
48    public function update_notice( $id, $status, $postponed_for = 0 ) {
49        delete_transient( self::NOTICES_CACHE_KEY );
50
51        return $this->request_as_blog(
52            array(
53                'timeout' => 5,
54                'method'  => 'POST',
55                'headers' => array( 'Content-Type' => 'application/json' ),
56            ),
57            wp_json_encode(
58                array(
59                    'id'            => $id,
60                    'status'        => $status,
61                    'postponed_for' => $postponed_for,
62                ),
63                JSON_UNESCAPED_SLASHES
64            )
65        );
66    }
67
68    /**
69     * Return an array of notice IDs as keys and a boolean flagging whether to show them.
70     *
71     * @param bool $bypass_cache Refetch the WPCOM dismissal state instead of using the cached copy.
72     * @return array
73     */
74    public function get_notices_to_show( bool $bypass_cache = false ) {
75        // Fetch the WPCOM map once and reuse it for every flag, so a force-refresh costs a single
76        // round-trip rather than one per shared-key lookup.
77        $notices_wpcom = $this->get_notices_from_wpcom( $bypass_cache );
78
79        $new_stats_enabled        = Stats_Options::get_option( 'enable_odyssey_stats' );
80        $stats_views              = intval( Stats_Options::get_option( 'views' ) );
81        $odyssey_stats_changed_at = intval( Stats_Options::get_option( 'odyssey_stats_changed_at' ) );
82
83        // Check if Jetpack is integrated with the Complianz plugin, which blocks the Stats.
84        $complianz_options_integrations  = get_option( 'complianz_options_integrations' );
85        $is_jetpack_blocked_by_complianz = ! isset( $complianz_options_integrations['jetpack'] ) || $complianz_options_integrations['jetpack'];
86
87        return array_merge(
88            $notices_wpcom,
89            array(
90                // Show Opt-in notice 30 days after the new stats being disabled.
91                self::OPT_IN_NEW_STATS_NOTICE_ID    => ! $new_stats_enabled
92                    && $odyssey_stats_changed_at < time() - self::POSTPONE_OPT_IN_NOTICE_DAYS * DAY_IN_SECONDS
93                    && ! $this->is_hidden( $notices_wpcom, self::OPT_IN_NEW_STATS_NOTICE_ID ),
94
95                // Show feedback notice after 3 views of the new stats.
96                self::NEW_STATS_FEEDBACK_NOTICE_ID  => $new_stats_enabled
97                    && $stats_views >= self::VIEWS_TO_SHOW_FEEDBACK
98                    && ! $this->is_hidden( $notices_wpcom, self::NEW_STATS_FEEDBACK_NOTICE_ID ),
99
100                // Show opt-out notice before 3 views of the new stats, where 3 is included.
101                self::OPT_OUT_NEW_STATS_NOTICE_ID   => $new_stats_enabled
102                    && $stats_views < self::VIEWS_TO_SHOW_FEEDBACK
103                    && ! $this->is_hidden( $notices_wpcom, self::OPT_OUT_NEW_STATS_NOTICE_ID ),
104
105                // GDPR cookie consent notice for Complianz users.
106                self::GDPR_COOKIE_CONSENT_NOTICE_ID => class_exists( 'COMPLIANZ' ) && $is_jetpack_blocked_by_complianz
107                    && ! $this->is_hidden( $notices_wpcom, self::GDPR_COOKIE_CONSENT_NOTICE_ID ),
108            )
109        );
110    }
111
112    /**
113     * Get the array of notice dismissal flags stored on WPCOM, cached for {@see CACHE_TTL}.
114     *
115     * @param bool $bypass_cache Refetch instead of returning the cached copy.
116     * @return array
117     */
118    public function get_notices_from_wpcom( bool $bypass_cache = false ) {
119        if ( ! $bypass_cache ) {
120            $cached = get_transient( self::NOTICES_CACHE_KEY );
121            if ( false !== $cached ) {
122                $decoded = json_decode( $cached, true );
123                return is_array( $decoded ) ? $decoded : array();
124            }
125        }
126
127        $notices_wpcom = $this->request_as_blog( array( 'timeout' => 5 ) );
128        if ( is_wp_error( $notices_wpcom ) ) {
129            return array();
130        }
131
132        set_transient( self::NOTICES_CACHE_KEY, wp_json_encode( $notices_wpcom, JSON_UNESCAPED_SLASHES ), self::CACHE_TTL );
133
134        return $notices_wpcom;
135    }
136
137    /**
138     * Checks if a notice is hidden, fetching the WPCOM dismissal state.
139     *
140     * @param mixed $id ID of the notice.
141     * @return bool
142     */
143    public function is_notice_hidden( $id ) {
144        return $this->is_hidden( $this->get_notices_from_wpcom(), $id );
145    }
146
147    /**
148     * Whether a notice is hidden in an already-fetched WPCOM dismissal map.
149     *
150     * @param array $notices_wpcom The WPCOM dismissal map.
151     * @param mixed $id            ID of the notice.
152     * @return bool
153     */
154    private function is_hidden( array $notices_wpcom, $id ) {
155        return array_key_exists( $id, $notices_wpcom ) && $notices_wpcom[ $id ] === false;
156    }
157
158    /**
159     * Send a blog-token request to the WPCOM notices endpoint and return its decoded body.
160     *
161     * @param array       $args Request arguments passed to the connection client.
162     * @param string|null $body Request body, for writes.
163     * @return array|WP_Error Decoded response body, or a WP_Error on transport or API error.
164     */
165    private function request_as_blog( array $args, $body = null ) {
166        $response = Client::wpcom_json_api_request_as_blog(
167            sprintf( '/sites/%d/jetpack-stats-dashboard/notices', Jetpack_Options::get_option( 'id' ) ),
168            'v2',
169            $args,
170            $body,
171            'wpcom'
172        );
173
174        if ( is_wp_error( $response ) ) {
175            return $response;
176        }
177
178        $response_code = (int) wp_remote_retrieve_response_code( $response );
179        $decoded       = json_decode( wp_remote_retrieve_body( $response ), true );
180
181        $error_code = null;
182        foreach ( array( 'code', 'error' ) as $key ) {
183            if ( isset( $decoded[ $key ] ) ) {
184                $error_code = $decoded[ $key ];
185                break;
186            }
187        }
188
189        if ( null !== $error_code || 200 !== $response_code ) {
190            // Fall back to a non-empty code so WP_Error keeps the status/message instead of
191            // constructing an empty error (WP_Error::__construct bails on an empty code).
192            return new WP_Error(
193                $error_code ?? 'notices_request_failed',
194                is_array( $decoded ) ? ( $decoded['message'] ?? 'unknown remote error' ) : 'unknown remote error',
195                array( 'status' => $response_code )
196            );
197        }
198
199        // The notices endpoint always returns a JSON object; coerce anything else to an empty map.
200        return is_array( $decoded ) ? $decoded : array();
201    }
202}