Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WP_REST_WPCOM_Block_Editor_Four_For_Four_Controller
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 7
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 register_rest_route
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 permission_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_four_for_four
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 set_four_for_four_status
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 get_user_status
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 is_site_eligible
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
306
1<?php
2/**
3 * WP_REST_WPCOM_Block_Editor_Four_For_Four_Controller file.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\NUX;
9
10use Automattic\Jetpack\Status;
11use Automattic\Jetpack\Status\Host;
12
13/**
14 * Class WP_REST_WPCOM_Block_Editor_Four_For_Four_Controller.
15 *
16 * Eligibility and opt-in state for the "4 for 4" prompt shown after a site's
17 * first post is published. The decision lives in user meta (global across a
18 * Simple user's sites) so the same writer is asked once, on one site.
19 */
20class WP_REST_WPCOM_Block_Editor_Four_For_Four_Controller extends \WP_REST_Controller {
21    /**
22     * User attribute holding the writer's decision. User attributes are the
23     * global per-user store on wpcom (user meta is per blog), and the wpcom
24     * Reader endpoints read this same attribute, adding `followed_blog_ids`
25     * and moving the status to `completed`, so writes here merge rather than
26     * replace.
27     */
28    const USER_ATTRIBUTE = 'wpcom_four_for_four';
29
30    /**
31     * Statuses the editor prompt may write.
32     *
33     * @var string[]
34     */
35    const EDITOR_STATUSES = array( 'opted_in', 'opted_out' );
36
37    /**
38     * WP_REST_WPCOM_Block_Editor_Four_For_Four_Controller constructor.
39     */
40    public function __construct() {
41        $this->namespace = 'wpcom/v2';
42        $this->rest_base = 'block-editor/four-for-four';
43    }
44
45    /**
46     * Register available routes.
47     */
48    public function register_rest_route() {
49        register_rest_route(
50            $this->namespace,
51            $this->rest_base,
52            array(
53                array(
54                    'methods'             => \WP_REST_Server::READABLE,
55                    'callback'            => array( $this, 'get_four_for_four' ),
56                    'permission_callback' => array( $this, 'permission_callback' ),
57                ),
58                array(
59                    'methods'             => \WP_REST_Server::EDITABLE,
60                    'callback'            => array( $this, 'set_four_for_four_status' ),
61                    'permission_callback' => array( $this, 'permission_callback' ),
62                    'args'                => array(
63                        'status' => array(
64                            'required'          => true,
65                            'type'              => 'string',
66                            'enum'              => self::EDITOR_STATUSES,
67                            'validate_callback' => 'rest_validate_request_arg',
68                        ),
69                    ),
70                ),
71            )
72        );
73    }
74
75    /**
76     * Callback to determine whether the request can proceed.
77     *
78     * @return boolean
79     */
80    public function permission_callback() {
81        return current_user_can( 'edit_posts' );
82    }
83
84    /**
85     * Whether the current user should be offered the program on this site.
86     *
87     * @return \WP_REST_Response
88     */
89    public function get_four_for_four() {
90        $eligible = $this->is_site_eligible() && ! isset( $this->get_user_status()['status'] );
91
92        return rest_ensure_response( array( 'eligible' => $eligible ) );
93    }
94
95    /**
96     * Record the writer's decision from the editor prompt.
97     *
98     * @param \WP_REST_Request $request Request object.
99     * @return \WP_REST_Response|\WP_Error
100     */
101    public function set_four_for_four_status( $request ) {
102        if ( ! function_exists( 'update_user_attribute' ) ) {
103            return new \WP_Error( 'four_for_four_unavailable', 'The program is not available on this site.', array( 'status' => 501 ) );
104        }
105
106        $status  = $request->get_param( 'status' );
107        $current = $this->get_user_status();
108
109        if ( isset( $current['status'] ) && 'completed' === $current['status'] ) {
110            return new \WP_Error( 'already_completed', 'The program has already been completed.', array( 'status' => 409 ) );
111        }
112
113        update_user_attribute(
114            get_current_user_id(),
115            self::USER_ATTRIBUTE,
116            array_merge(
117                $current,
118                array(
119                    'status'  => $status,
120                    'blog_id' => (int) get_current_blog_id(),
121                    'updated' => time(),
122                )
123            )
124        );
125
126        return rest_ensure_response( array( 'status' => $status ) );
127    }
128
129    /**
130     * The stored decision for the current user, or an empty array.
131     *
132     * @return array
133     */
134    private function get_user_status() {
135        if ( ! function_exists( 'get_user_attribute' ) ) {
136            return array();
137        }
138        $state = get_user_attribute( get_current_user_id(), self::USER_ATTRIBUTE );
139        return is_array( $state ) ? $state : array();
140    }
141
142    /**
143     * Whether this site qualifies for the prompt. Like the sibling first-post
144     * controller, this is read when the editor loads, while
145     * `has_never_published_post` is still set for the post being written.
146     *
147     * @return boolean
148     */
149    private function is_site_eligible() {
150        /**
151         * Enables the 4 for 4 prompt. Off by default so this package can ship
152         * ahead of the wpcom endpoints the Reader page depends on; wpcom turns
153         * it on once those are live.
154         *
155         * @param bool $enabled Whether the prompt may be shown. Default false.
156         */
157        if ( ! apply_filters( 'wpcom_four_for_four_enabled', false ) ) {
158            return false;
159        }
160
161        $host = new Host();
162        if ( ! $host->is_wpcom_simple() ) {
163            return false;
164        }
165
166        if ( ! get_option( 'has_never_published_post', false ) ) {
167            return false;
168        }
169
170        // Sites created before the launch flow have no launch status and count as launched.
171        $launch_status = get_option( 'launch-status' );
172        if ( $launch_status && 'launched' !== $launch_status ) {
173            return false;
174        }
175
176        // Both Coming Soon generations: the v1 option paired with a private blog, and the public v2 flag.
177        // wpcom_is_coming_soon() lives in wpcom and has no Phan stub.
178        // @phan-suppress-next-line PhanUndeclaredFunction
179        if ( function_exists( 'wpcom_is_coming_soon' ) && wpcom_is_coming_soon() ) {
180            return false;
181        }
182        if ( function_exists( 'is_wpcom_public_coming_soon_enabled' ) && is_wpcom_public_coming_soon_enabled( get_current_blog_id() ) ) {
183            return false;
184        }
185
186        if ( ( new Status() )->is_private_site() ) {
187            return false;
188        }
189
190        if ( ! str_starts_with( get_locale(), 'en' ) ) {
191            return false;
192        }
193
194        if ( $host->is_p2_site() ) {
195            return false;
196        }
197
198        $blog_id = get_current_blog_id();
199
200        // Spam, deleted, archived, mature, suspended and hidden sites are all excluded here.
201        // @phan-suppress-next-line PhanUndeclaredFunction
202        if ( function_exists( 'is_public_to_people' ) && ! is_public_to_people( $blog_id ) ) {
203            return false;
204        }
205
206        /**
207         * Blog stickers that exclude a site from the 4 for 4 prompt.
208         *
209         * @param string[] $stickers Sticker names.
210         */
211        $blocked_stickers = apply_filters(
212            'wpcom_four_for_four_blocked_stickers',
213            array( 'broken-in-reader', 'is_disconnected', 'dont-recommend', 'a8c-test-blog', 'a8c-e2e-test-blog' )
214        );
215        foreach ( $blocked_stickers as $sticker ) {
216            if ( wpcom_has_blog_sticker( $sticker, $blog_id ) ) {
217                return false;
218            }
219        }
220
221        return true;
222    }
223}