Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.90% covered (success)
98.90%
90 / 91
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Notices
98.90% covered (success)
98.90%
90 / 91
87.50% covered (warning)
87.50%
7 / 8
27
0.00% covered (danger)
0.00%
0 / 1
 update_notice
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 clear_cache
100.00% covered (success)
100.00%
2 / 2
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
11
 to_detail_records
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_notices_from_wpcom
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 is_notice_hidden
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_hidden
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 to_detail_record
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * A class that handles the notices for the Stats Admin dashboard.
4 *
5 * @package automattic/jetpack-stats-admin
6 */
7
8namespace Automattic\Jetpack\Stats_Admin;
9
10use Automattic\Jetpack\Stats\Options as Stats_Options;
11use Jetpack_Options;
12
13/**
14 * The Notices class handles the notices for the Stats Admin dashboard.
15 *
16 * @package Automattic\Jetpack\Stats_Admin
17 */
18class Notices {
19    const STATS_DASHBOARD_NOTICES_CACHE_KEY = 'jetpack_stats_dashboard_notices_cache_key';
20
21    /**
22     * The flat map and the detail records are different shapes of the same resource, so they
23     * cannot share a transient.
24     */
25    const STATS_DASHBOARD_NOTICES_DETAILS_CACHE_KEY = self::STATS_DASHBOARD_NOTICES_CACHE_KEY . '_details';
26
27    const OPT_OUT_NEW_STATS_NOTICE_ID   = 'opt_out_new_stats';
28    const NEW_STATS_FEEDBACK_NOTICE_ID  = 'new_stats_feedback';
29    const OPT_IN_NEW_STATS_NOTICE_ID    = 'opt_in_new_stats';
30    const GDPR_COOKIE_CONSENT_NOTICE_ID = 'gdpr_cookie_consent';
31
32    const VIEWS_TO_SHOW_FEEDBACK      = 3;
33    const POSTPONE_OPT_IN_NOTICE_DAYS = 30;
34
35    /**
36     * Update notice status.
37     *
38     * @param mixed $id ID of the notice.
39     * @param mixed $status Status of the notice.
40     * @param int   $postponed_for Postponed for how many seconds.
41     * @return bool
42     */
43    public function update_notice( $id, $status, $postponed_for = 0 ) {
44        $this->clear_cache();
45        $response = WPCOM_Client::request_as_blog(
46            sprintf(
47                '/sites/%d/jetpack-stats-dashboard/notices',
48                Jetpack_Options::get_option( 'id' )
49            ),
50            'v2',
51            array(
52                'timeout' => 5,
53                'method'  => 'POST',
54                'headers' => array(
55                    'Content-Type' => 'application/json',
56                ),
57            ),
58            wp_json_encode(
59                array(
60                    'id'            => $id,
61                    'status'        => $status,
62                    'postponed_for' => $postponed_for,
63                ),
64                JSON_UNESCAPED_SLASHES
65            ),
66            'wpcom'
67        );
68        // A notices GET running alongside this POST can cache the pre-dismissal answer while the POST is in flight.
69        $this->clear_cache();
70        return $response;
71    }
72
73    /**
74     * Drop both cached shapes of the WPCOM notices response.
75     */
76    private function clear_cache() {
77        delete_transient( self::STATS_DASHBOARD_NOTICES_CACHE_KEY );
78        delete_transient( self::STATS_DASHBOARD_NOTICES_DETAILS_CACHE_KEY );
79    }
80
81    /**
82     * Return an array of notices IDs as keys and their value to flag whether to show them.
83     *
84     * @param bool $include_details Return a detail record per notice instead of a bare flag.
85     * @return array
86     */
87    public function get_notices_to_show( bool $include_details = false ) {
88        // Reuse the one fetch for every flag below, so details mode doesn't also pull the flat map.
89        $notices_wpcom = $this->get_notices_from_wpcom( $include_details );
90
91        $new_stats_enabled        = Stats_Options::get_option( 'enable_odyssey_stats' );
92        $stats_views              = intval( Stats_Options::get_option( 'views' ) );
93        $odyssey_stats_changed_at = intval( Stats_Options::get_option( 'odyssey_stats_changed_at' ) );
94
95        // Check if Jetpack is integrated with the Complianz plugin, which blocks the Stats.
96        $complianz_options_integrations  = get_option( 'complianz_options_integrations' );
97        $is_jetpack_blocked_by_complianz = ! isset( $complianz_options_integrations['jetpack'] ) || $complianz_options_integrations['jetpack'];
98
99        $local_notices = array(
100            // Show Opt-in notice 30 days after the new stats being disabled.
101            self::OPT_IN_NEW_STATS_NOTICE_ID    => ! $new_stats_enabled
102                && $odyssey_stats_changed_at < time() - self::POSTPONE_OPT_IN_NOTICE_DAYS * DAY_IN_SECONDS
103                && ! $this->is_hidden( $notices_wpcom, self::OPT_IN_NEW_STATS_NOTICE_ID ),
104
105            // Show feedback notice after 3 views of the new stats.
106            self::NEW_STATS_FEEDBACK_NOTICE_ID  => $new_stats_enabled
107                && $stats_views >= self::VIEWS_TO_SHOW_FEEDBACK
108                && ! $this->is_hidden( $notices_wpcom, self::NEW_STATS_FEEDBACK_NOTICE_ID ),
109
110            // Show opt-out notice before 3 views of the new stats, where 3 is included.
111            self::OPT_OUT_NEW_STATS_NOTICE_ID   => $new_stats_enabled
112                && $stats_views < self::VIEWS_TO_SHOW_FEEDBACK
113                && ! $this->is_hidden( $notices_wpcom, self::OPT_OUT_NEW_STATS_NOTICE_ID ),
114
115            // GDPR cookie consent notice for Complianz users.
116            self::GDPR_COOKIE_CONSENT_NOTICE_ID => class_exists( 'COMPLIANZ' ) && $is_jetpack_blocked_by_complianz
117                && ! $this->is_hidden( $notices_wpcom, self::GDPR_COOKIE_CONSENT_NOTICE_ID ),
118        );
119
120        if ( $include_details ) {
121            return $this->to_detail_records( $notices_wpcom, $local_notices );
122        }
123
124        return array_merge( $notices_wpcom, $local_notices );
125    }
126
127    /**
128     * Normalize every notice to the detail shape, whichever shape WPCOM answered in.
129     *
130     * The package ships to self-hosted sites and can run for months against a WPCOM that does not
131     * serve `include_details` yet, so a passed-through flat value would leave the caller parsing
132     * two shapes in one response.
133     *
134     * @param array $notices_wpcom The WPCOM response, flat map or detail records.
135     * @param array $local_notices The locally-computed visibility flags.
136     * @return array
137     */
138    private function to_detail_records( array $notices_wpcom, array $local_notices ) {
139        $notices = array();
140
141        foreach ( array_keys( array_merge( $notices_wpcom, $local_notices ) ) as $id ) {
142            // A local notice keeps its own visibility; its escalation fields still come from WPCOM.
143            $show = array_key_exists( $id, $local_notices )
144                ? (bool) $local_notices[ $id ]
145                : ! $this->is_hidden( $notices_wpcom, $id );
146
147            $notices[ $id ] = $this->to_detail_record( $notices_wpcom[ $id ] ?? null, $show );
148        }
149
150        return $notices;
151    }
152
153    /**
154     * Get the array of hidden notices from WPCOM.
155     *
156     * @param bool $include_details Ask WPCOM for detail records instead of a flat map.
157     * @return array
158     */
159    public function get_notices_from_wpcom( bool $include_details = false ) {
160        $path = sprintf(
161            '/sites/%d/jetpack-stats-dashboard/notices',
162            Jetpack_Options::get_option( 'id' )
163        );
164
165        if ( $include_details ) {
166            $path .= '?include_details=true';
167        }
168
169        $notices_wpcom = WPCOM_Client::request_as_blog_cached(
170            $path,
171            'v2',
172            array(
173                'timeout' => 5,
174            ),
175            null,
176            'wpcom',
177            true,
178            $include_details
179                ? static::STATS_DASHBOARD_NOTICES_DETAILS_CACHE_KEY
180                : static::STATS_DASHBOARD_NOTICES_CACHE_KEY
181        );
182
183        if ( is_wp_error( $notices_wpcom ) ) {
184            return array();
185        }
186        return $notices_wpcom;
187    }
188
189    /**
190     * Checks if a notice is hidden.
191     *
192     * @param mixed $id ID of the notice.
193     * @return bool
194     */
195    public function is_notice_hidden( $id ) {
196        return $this->is_hidden( $this->get_notices_from_wpcom(), $id );
197    }
198
199    /**
200     * Whether a notice is hidden in an already-fetched WPCOM response, in either shape.
201     *
202     * @param array $notices_wpcom The WPCOM response, flat map or detail records.
203     * @param mixed $id            ID of the notice.
204     * @return bool
205     */
206    private function is_hidden( array $notices_wpcom, $id ) {
207        if ( ! array_key_exists( $id, $notices_wpcom ) ) {
208            return false;
209        }
210
211        $record = $notices_wpcom[ $id ];
212
213        return is_array( $record ) ? empty( $record['show'] ) : $record === false;
214    }
215
216    /**
217     * Wrap a locally-computed flag in the detail shape, filling the escalation fields from WPCOM.
218     *
219     * @param mixed $wpcom_record The WPCOM detail record for this notice, or null when it has none.
220     * @param bool  $show         The locally-computed visibility flag.
221     * @return array
222     */
223    private function to_detail_record( $wpcom_record, bool $show ) {
224        $record = is_array( $wpcom_record ) ? $wpcom_record : array();
225
226        return array(
227            'show'            => $show,
228            'status'          => $record['status'] ?? null,
229            'postponed_count' => (int) ( $record['postponed_count'] ?? 0 ),
230            // A non-numeric value would cast to 0, which reads as "due now" rather than "not scheduled".
231            'next_show_at'    => is_numeric( $record['next_show_at'] ?? null ) ? (int) $record['next_show_at'] : null,
232        );
233    }
234}