Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.96% covered (warning)
67.96%
369 / 543
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Subscribers_List
68.33% covered (warning)
68.33%
369 / 540
25.00% covered (danger)
25.00%
4 / 16
314.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
233 / 233
100.00% covered (success)
100.00%
1 / 1
3
 permission_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_subscribers
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 get_subscriber_totals
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 remove_subscriber
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
9
 get_subscriber_individual
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
7.90
 get_subscriber_stats
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
5.93
 add_subscribers
95.56% covered (success)
95.56%
43 / 45
0.00% covered (danger)
0.00%
0 / 1
8
 get_import_jobs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 reset_import_state
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
4.59
 get_memberships_products
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 add_comp
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 remove_comp
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 get_wpcom_error_message
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 wpcom_get
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Subscribers List REST endpoint.
4 *
5 * Proxies the WP.com `/wpcom/v2/sites/{blog_id}/subscribers` endpoint so the
6 * Subscribers Dashboard wp-admin page can render a live DataViews table on
7 * Jetpack-connected self-hosted sites.
8 *
9 * @package automattic/jetpack
10 */
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19/**
20 * Class WPCOM_REST_API_V2_Endpoint_Subscribers_List
21 */
22class WPCOM_REST_API_V2_Endpoint_Subscribers_List extends WP_REST_Controller {
23
24    /**
25     * Constructor.
26     */
27    public function __construct() {
28        $this->namespace = 'wpcom/v2';
29        $this->rest_base = 'subscribers/list';
30
31        // On WPCOM the matching endpoint is registered via the wpcom-rest-api-v2 plugin loader,
32        // where it talks to subscriber storage directly.
33        $this->wpcom_is_wpcom_only_endpoint = true;
34
35        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
36    }
37
38    /**
39     * Register routes.
40     *
41     * Gated behind the same `rsm_jetpack_ui_modernization_newsletter` filter the dashboard UI uses
42     * (mirrors `Automattic\Jetpack\Newsletter\Settings::MODERNIZATION_FILTER`). Checked here, on
43     * `rest_api_init`, so theme-added filters have a chance to land before the gate evaluates.
44     *
45     * The filter defaults on for every site; hosts can opt out with
46     * `add_filter( 'rsm_jetpack_ui_modernization_newsletter', '__return_false' )`.
47     */
48    public function register_routes() {
49        if ( ! apply_filters( 'rsm_jetpack_ui_modernization_newsletter', true ) ) {
50            return;
51        }
52
53        register_rest_route(
54            $this->namespace,
55            '/subscribers/add',
56            array(
57                array(
58                    'methods'             => WP_REST_Server::CREATABLE,
59                    'callback'            => array( $this, 'add_subscribers' ),
60                    'permission_callback' => array( $this, 'permission_check' ),
61                    'args'                => array(
62                        'emails' => array(
63                            'type'              => 'array',
64                            'items'             => array( 'type' => 'string' ),
65                            'required'          => true,
66                            'validate_callback' => function ( $value ) {
67                                return is_array( $value ) && count( $value ) > 0;
68                            },
69                        ),
70                    ),
71                ),
72            )
73        );
74
75        register_rest_route(
76            $this->namespace,
77            '/subscribers/remove',
78            array(
79                array(
80                    'methods'             => WP_REST_Server::CREATABLE,
81                    'callback'            => array( $this, 'remove_subscriber' ),
82                    'permission_callback' => array( $this, 'permission_check' ),
83                    'args'                => array(
84                        'user_id'               => array(
85                            'type'    => 'integer',
86                            'default' => 0,
87                            'minimum' => 0,
88                        ),
89                        'email_subscription_id' => array(
90                            'type'    => 'integer',
91                            'default' => 0,
92                            'minimum' => 0,
93                        ),
94                        'paid_subscription_ids' => array(
95                            'type'    => 'array',
96                            'items'   => array( 'type' => 'string' ),
97                            'default' => array(),
98                        ),
99                    ),
100                ),
101            )
102        );
103
104        register_rest_route(
105            $this->namespace,
106            '/subscribers/individual',
107            array(
108                array(
109                    'methods'             => WP_REST_Server::READABLE,
110                    'callback'            => array( $this, 'get_subscriber_individual' ),
111                    'permission_callback' => array( $this, 'permission_check' ),
112                    'args'                => array(
113                        'subscription_id' => array(
114                            'type'    => 'integer',
115                            'default' => 0,
116                            'minimum' => 0,
117                        ),
118                        'user_id'         => array(
119                            'type'    => 'integer',
120                            'default' => 0,
121                            'minimum' => 0,
122                        ),
123                    ),
124                ),
125            )
126        );
127
128        register_rest_route(
129            $this->namespace,
130            '/subscribers/individual-stats',
131            array(
132                array(
133                    'methods'             => WP_REST_Server::READABLE,
134                    'callback'            => array( $this, 'get_subscriber_stats' ),
135                    'permission_callback' => array( $this, 'permission_check' ),
136                    'args'                => array(
137                        'subscription_id' => array(
138                            'type'    => 'integer',
139                            'default' => 0,
140                            'minimum' => 0,
141                        ),
142                        'user_id'         => array(
143                            'type'    => 'integer',
144                            'default' => 0,
145                            'minimum' => 0,
146                        ),
147                    ),
148                ),
149            )
150        );
151
152        register_rest_route(
153            $this->namespace,
154            '/subscribers/import',
155            array(
156                array(
157                    'methods'             => WP_REST_Server::READABLE,
158                    'callback'            => array( $this, 'get_import_jobs' ),
159                    'permission_callback' => array( $this, 'permission_check' ),
160                ),
161            )
162        );
163
164        register_rest_route(
165            $this->namespace,
166            '/subscribers/import/reset-state',
167            array(
168                array(
169                    'methods'             => WP_REST_Server::CREATABLE,
170                    'callback'            => array( $this, 'reset_import_state' ),
171                    'permission_callback' => array( $this, 'permission_check' ),
172                ),
173            )
174        );
175
176        register_rest_route(
177            $this->namespace,
178            '/subscribers/totals',
179            array(
180                array(
181                    'methods'             => WP_REST_Server::READABLE,
182                    'callback'            => array( $this, 'get_subscriber_totals' ),
183                    'permission_callback' => array( $this, 'permission_check' ),
184                ),
185            )
186        );
187
188        register_rest_route(
189            $this->namespace,
190            '/' . $this->rest_base,
191            array(
192                array(
193                    'methods'             => WP_REST_Server::READABLE,
194                    'callback'            => array( $this, 'get_subscribers' ),
195                    'permission_callback' => array( $this, 'permission_check' ),
196                    'args'                => array(
197                        'page'           => array(
198                            'type'    => 'integer',
199                            'default' => 1,
200                            'minimum' => 1,
201                        ),
202                        'per_page'       => array(
203                            'type'    => 'integer',
204                            'default' => 10,
205                            'minimum' => 1,
206                            'maximum' => 100,
207                        ),
208                        'sort'           => array(
209                            'type'    => 'string',
210                            'default' => 'date_subscribed',
211                            'enum'    => array( 'date_subscribed', 'name', 'plan', 'subscription_status' ),
212                        ),
213                        'sort_order'     => array(
214                            'type'    => 'string',
215                            'default' => 'desc',
216                            'enum'    => array( 'asc', 'desc' ),
217                        ),
218                        'search'         => array(
219                            'type'    => 'string',
220                            'default' => '',
221                        ),
222                        'filters'        => array(
223                            'type'    => 'array',
224                            'items'   => array( 'type' => 'string' ),
225                            'default' => array( 'all' ),
226                        ),
227                        'use_new_helper' => array(
228                            'type'    => 'boolean',
229                            'default' => true,
230                        ),
231                    ),
232                ),
233            )
234        );
235
236        register_rest_route(
237            $this->namespace,
238            '/subscribers/products',
239            array(
240                array(
241                    'methods'             => WP_REST_Server::READABLE,
242                    'callback'            => array( $this, 'get_memberships_products' ),
243                    'permission_callback' => array( $this, 'permission_check' ),
244                ),
245            )
246        );
247
248        register_rest_route(
249            $this->namespace,
250            '/subscribers/comp',
251            array(
252                array(
253                    'methods'             => WP_REST_Server::CREATABLE,
254                    'callback'            => array( $this, 'add_comp' ),
255                    'permission_callback' => array( $this, 'permission_check' ),
256                    'args'                => array(
257                        'user_id'       => array(
258                            'type'     => 'integer',
259                            'required' => true,
260                            'minimum'  => 1,
261                        ),
262                        'plan_id'       => array(
263                            'type'     => 'integer',
264                            'required' => true,
265                            'minimum'  => 1,
266                        ),
267                        'no_expiration' => array(
268                            'type'    => 'boolean',
269                            'default' => false,
270                        ),
271                    ),
272                ),
273            )
274        );
275
276        register_rest_route(
277            $this->namespace,
278            '/subscribers/remove-comp',
279            array(
280                array(
281                    'methods'             => WP_REST_Server::CREATABLE,
282                    'callback'            => array( $this, 'remove_comp' ),
283                    'permission_callback' => array( $this, 'permission_check' ),
284                    'args'                => array(
285                        'comp_id' => array(
286                            'type'     => 'integer',
287                            'required' => true,
288                            'minimum'  => 1,
289                        ),
290                    ),
291                ),
292            )
293        );
294    }
295
296    /**
297     * Permission check — manage_options matches the wp-admin Subscribers menu cap.
298     *
299     * @return true|WP_Error
300     */
301    public function permission_check() {
302        if ( ! current_user_can( 'manage_options' ) ) {
303            return new WP_Error(
304                'authorization_required',
305                __( 'You are not allowed to view subscribers for this site.', 'jetpack' ),
306                array( 'status' => rest_authorization_required_code() )
307            );
308        }
309
310        return true;
311    }
312
313    /**
314     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers.
315     *
316     * @param WP_REST_Request $request Request.
317     * @return WP_REST_Response|WP_Error
318     */
319    public function get_subscribers( $request ) {
320        $blog_id = Connection_Manager::get_site_id();
321
322        if ( is_wp_error( $blog_id ) ) {
323            return $blog_id;
324        }
325
326        $query = array(
327            'page'           => (int) $request->get_param( 'page' ),
328            'per_page'       => (int) $request->get_param( 'per_page' ),
329            'sort'           => (string) $request->get_param( 'sort' ),
330            'sort_order'     => (string) $request->get_param( 'sort_order' ),
331            'use_new_helper' => $request->get_param( 'use_new_helper' ) ? 'true' : 'false',
332        );
333
334        $search = (string) $request->get_param( 'search' );
335        if ( '' !== $search ) {
336            $query['search'] = $search;
337        }
338
339        $query_string = http_build_query( $query );
340
341        $filters = (array) $request->get_param( 'filters' );
342        foreach ( $filters as $filter ) {
343            $query_string .= '&' . rawurlencode( 'filters[]' ) . '=' . rawurlencode( (string) $filter );
344        }
345
346        $path = sprintf( '/sites/%d/subscribers?%s', (int) $blog_id, $query_string );
347
348        $response = Client::wpcom_json_api_request_as_user(
349            $path,
350            '2',
351            array( 'method' => 'GET' ),
352            null,
353            'wpcom'
354        );
355
356        if ( is_wp_error( $response ) ) {
357            return $response;
358        }
359
360        $status = (int) wp_remote_retrieve_response_code( $response );
361        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
362
363        if ( $status >= 400 ) {
364            return new WP_Error(
365                'subscribers_list_failed',
366                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not fetch subscribers.', 'jetpack' ),
367                array( 'status' => $status )
368            );
369        }
370
371        return rest_ensure_response( $body );
372    }
373
374    /**
375     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/counts.
376     *
377     * @return WP_REST_Response|WP_Error
378     */
379    public function get_subscriber_totals() {
380        $blog_id = Connection_Manager::get_site_id();
381
382        if ( is_wp_error( $blog_id ) ) {
383            return $blog_id;
384        }
385
386        $response = Client::wpcom_json_api_request_as_user(
387            sprintf( '/sites/%d/subscribers/counts', (int) $blog_id ),
388            '2',
389            array( 'method' => 'GET' ),
390            null,
391            'wpcom'
392        );
393
394        if ( is_wp_error( $response ) ) {
395            return $response;
396        }
397
398        $status = (int) wp_remote_retrieve_response_code( $response );
399        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
400
401        if ( $status >= 400 ) {
402            return new WP_Error(
403                'subscribers_totals_failed',
404                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not fetch subscriber totals.', 'jetpack' ),
405                array( 'status' => $status )
406            );
407        }
408
409        return rest_ensure_response( $body );
410    }
411
412    /**
413     * Remove a subscriber by cancelling any paid subscriptions and deleting both the WPCOM
414     * follower and email follower records, mirroring Calypso's `useSubscriberRemoveMutation`.
415     *
416     * Proxies to the consolidated wpcom `/sites/{blog_id}/subscribers/remove` (v2) endpoint, which
417     * runs all three steps in-process after switching to the blog and returns an aggregated
418     * `{ ok, errors }` result. Forwarded as the current user (not the blog token): the wpcom/v2
419     * authorization layer maps the Jetpack user token to the wpcom user, so the endpoint's
420     * `manage_options` gate evaluates against the acting admin — unlike the classic v1.1 `/rest`
421     * API, which left the request with no current user.
422     *
423     * @param WP_REST_Request $request Request.
424     * @return WP_REST_Response|WP_Error
425     */
426    public function remove_subscriber( $request ) {
427        $blog_id = Connection_Manager::get_site_id();
428
429        if ( is_wp_error( $blog_id ) ) {
430            return $blog_id;
431        }
432
433        $user_id               = (int) $request->get_param( 'user_id' );
434        $email_subscription_id = (int) $request->get_param( 'email_subscription_id' );
435        $paid_subscription_ids = array_values(
436            array_filter( array_map( 'strval', (array) $request->get_param( 'paid_subscription_ids' ) ) )
437        );
438
439        if ( ! $user_id && ! $email_subscription_id && empty( $paid_subscription_ids ) ) {
440            return new WP_Error(
441                'subscribers_remove_invalid',
442                __( 'No subscriber identifiers were provided.', 'jetpack' ),
443                array( 'status' => 400 )
444            );
445        }
446
447        $response = Client::wpcom_json_api_request_as_user(
448            sprintf( '/sites/%d/subscribers/remove', (int) $blog_id ),
449            '2',
450            array(
451                'method'  => 'POST',
452                'headers' => array( 'Content-Type' => 'application/json' ),
453            ),
454            wp_json_encode(
455                array(
456                    'user_id'               => $user_id,
457                    'email_subscription_id' => $email_subscription_id,
458                    'paid_subscription_ids' => $paid_subscription_ids,
459                ),
460                JSON_UNESCAPED_SLASHES
461            ),
462            'wpcom'
463        );
464
465        if ( is_wp_error( $response ) ) {
466            return $response;
467        }
468
469        $status = (int) wp_remote_retrieve_response_code( $response );
470        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
471
472        if ( $status >= 400 ) {
473            return new WP_Error(
474                'subscribers_remove_failed',
475                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not remove the subscriber.', 'jetpack' ),
476                array( 'status' => $status )
477            );
478        }
479
480        return rest_ensure_response( $body );
481    }
482
483    /**
484     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/individual.
485     *
486     * @param WP_REST_Request $request Request.
487     * @return WP_REST_Response|WP_Error
488     */
489    public function get_subscriber_individual( $request ) {
490        $blog_id         = Connection_Manager::get_site_id();
491        $subscription_id = (int) $request->get_param( 'subscription_id' );
492        $user_id         = (int) $request->get_param( 'user_id' );
493
494        if ( is_wp_error( $blog_id ) ) {
495            return $blog_id;
496        }
497
498        if ( ! $subscription_id && ! $user_id ) {
499            return new WP_Error(
500                'subscriber_individual_missing_id',
501                __( 'Provide either a subscription_id or a user_id.', 'jetpack' ),
502                array( 'status' => 400 )
503            );
504        }
505
506        $type  = $user_id ? 'wpcom' : 'email';
507        $query = $user_id
508            ? sprintf( 'user_id=%d&type=%s', $user_id, rawurlencode( $type ) )
509            : sprintf( 'subscription_id=%d&type=%s', $subscription_id, rawurlencode( $type ) );
510
511        return $this->wpcom_get( sprintf( '/sites/%d/subscribers/individual?%s', (int) $blog_id, $query ) );
512    }
513
514    /**
515     * Proxy GET /wpcom/v2/sites/{blog_id}/individual-subscriber-stats.
516     *
517     * @param WP_REST_Request $request Request.
518     * @return WP_REST_Response|WP_Error
519     */
520    public function get_subscriber_stats( $request ) {
521        $blog_id         = Connection_Manager::get_site_id();
522        $subscription_id = (int) $request->get_param( 'subscription_id' );
523        $user_id         = (int) $request->get_param( 'user_id' );
524
525        if ( is_wp_error( $blog_id ) ) {
526            return $blog_id;
527        }
528
529        if ( ! $subscription_id && ! $user_id ) {
530            return new WP_Error(
531                'subscriber_stats_missing_id',
532                __( 'Provide either a subscription_id or a user_id.', 'jetpack' ),
533                array( 'status' => 400 )
534            );
535        }
536
537        $query = $user_id
538            ? sprintf( 'user_id=%d', $user_id )
539            : sprintf( 'subscription_id=%d', $subscription_id );
540
541        return $this->wpcom_get( sprintf( '/sites/%d/individual-subscriber-stats?%s', (int) $blog_id, $query ) );
542    }
543
544    /**
545     * Add subscribers by email — proxies to `/sites/{blog_id}/subscribers/import` (v2), the same
546     * async import job Calypso's Add Subscribers modal starts. Addresses are imported directly as
547     * subscribers (no invitation email); WP.com processes the job in the background and emails the
548     * importing user a "Subscriber import completed" summary when it finishes.
549     *
550     * @param WP_REST_Request $request Request.
551     * @return WP_REST_Response|WP_Error
552     */
553    public function add_subscribers( $request ) {
554        $blog_id = Connection_Manager::get_site_id();
555
556        if ( is_wp_error( $blog_id ) ) {
557            return $blog_id;
558        }
559
560        $emails = (array) $request->get_param( 'emails' );
561        $emails = array_values(
562            array_filter(
563                array_map( 'sanitize_email', $emails ),
564                static function ( $email ) {
565                    return is_email( $email );
566                }
567            )
568        );
569
570        if ( empty( $emails ) ) {
571            return new WP_Error(
572                'subscribers_add_no_valid_emails',
573                __( 'Provide at least one valid email address.', 'jetpack' ),
574                array( 'status' => 400 )
575            );
576        }
577
578        // JSON body, not the form encoding Calypso submits: WP.com's Jetpack signature verifier
579        // canonicalizes `application/x-www-form-urlencoded` bodies differently from the Jetpack
580        // client (it re-encodes the parsed array as JSON before hashing), so a form-encoded POST
581        // fails the body-hash check and arrives unauthenticated (user 0) — surfacing as a 401
582        // `invalid_capabilities`. JSON bodies hash identically on both sides, and the endpoint
583        // reads its params from either encoding. `parse_only => false` runs the import rather
584        // than only validating the payload.
585        $response = Client::wpcom_json_api_request_as_user(
586            sprintf( '/sites/%d/subscribers/import', (int) $blog_id ),
587            '2',
588            array(
589                'method'  => 'POST',
590                'headers' => array( 'Content-Type' => 'application/json' ),
591            ),
592            wp_json_encode(
593                array(
594                    'emails'     => $emails,
595                    'parse_only' => false,
596                ),
597                JSON_UNESCAPED_SLASHES
598            ),
599            'wpcom'
600        );
601
602        if ( is_wp_error( $response ) ) {
603            return $response;
604        }
605
606        $status = (int) wp_remote_retrieve_response_code( $response );
607        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
608
609        // A successful import start carries the async job id as `upload_id`. Mirror Calypso, which
610        // treats any response without one as a failure even when the HTTP status is 2xx.
611        if ( $status >= 400 || ! is_array( $body ) || empty( $body['upload_id'] ) ) {
612            return new WP_Error(
613                'subscribers_add_failed',
614                $this->get_wpcom_error_message( $body, __( 'Could not add subscribers.', 'jetpack' ) ),
615                array( 'status' => $status >= 400 ? $status : 400 )
616            );
617        }
618
619        return rest_ensure_response( $body );
620    }
621
622    /**
623     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/import — the site's subscriber import jobs,
624     * newest first. The dashboard polls this while the Add Subscribers modal is open so it can
625     * show the "import in progress" / stale-import notices (WP.com runs one import per site at a
626     * time).
627     *
628     * @return WP_REST_Response|WP_Error
629     */
630    public function get_import_jobs() {
631        $blog_id = Connection_Manager::get_site_id();
632
633        if ( is_wp_error( $blog_id ) ) {
634            return $blog_id;
635        }
636
637        return $this->wpcom_get( sprintf( '/sites/%d/subscribers/import', (int) $blog_id ) );
638    }
639
640    /**
641     * POST /wpcom/v2/subscribers/import/reset-state — cancel stuck (pending / importing)
642     * subscriber import jobs, mirroring Calypso's stale-import "Cancel import" action
643     * (`useSubscriberImportStatusReset`). Proxies to the wpcom
644     * `/sites/{blog_id}/subscribers/import/reset_state` endpoint and returns its
645     * `{ reset_count }` body.
646     *
647     * @return WP_REST_Response|WP_Error
648     */
649    public function reset_import_state() {
650        $blog_id = Connection_Manager::get_site_id();
651
652        if ( is_wp_error( $blog_id ) ) {
653            return $blog_id;
654        }
655
656        $response = Client::wpcom_json_api_request_as_user(
657            sprintf( '/sites/%d/subscribers/import/reset_state', (int) $blog_id ),
658            '2',
659            array( 'method' => 'POST' ),
660            null,
661            'wpcom'
662        );
663
664        if ( is_wp_error( $response ) ) {
665            return $response;
666        }
667
668        $status = (int) wp_remote_retrieve_response_code( $response );
669        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
670
671        if ( $status >= 400 ) {
672            return new WP_Error(
673                'subscribers_reset_import_failed',
674                $this->get_wpcom_error_message( $body, __( 'Could not cancel the import.', 'jetpack' ) ),
675                array( 'status' => $status )
676            );
677        }
678
679        return rest_ensure_response( $body );
680    }
681
682    /**
683     * Proxy GET /wpcom/v2/sites/{blog_id}/memberships/products?type=all&is_editable=true — the
684     * paid newsletter / membership tiers configured on this site. Used by the Comp-a-subscription
685     * plan picker so the modal can offer "comp this subscriber on plan X".
686     *
687     * @return WP_REST_Response|WP_Error
688     */
689    public function get_memberships_products() {
690        $blog_id = Connection_Manager::get_site_id();
691
692        if ( is_wp_error( $blog_id ) ) {
693            return $blog_id;
694        }
695
696        return $this->wpcom_get(
697            sprintf(
698                '/sites/%d/memberships/products?type=all&is_editable=true',
699                (int) $blog_id
700            )
701        );
702    }
703
704    /**
705     * POST /wpcom/v2/subscribers/comp — issue a complimentary subscription on a paid membership
706     * product for a single subscriber. Mirrors Calypso's `requestAddComp` thunk, which POSTs to
707     * `/sites/{id}/memberships/comps/{user_id}/{plan_id}`.
708     *
709     * @param WP_REST_Request $request Request.
710     * @return WP_REST_Response|WP_Error
711     */
712    public function add_comp( $request ) {
713        $blog_id = Connection_Manager::get_site_id();
714
715        if ( is_wp_error( $blog_id ) ) {
716            return $blog_id;
717        }
718
719        $user_id       = (int) $request->get_param( 'user_id' );
720        $plan_id       = (int) $request->get_param( 'plan_id' );
721        $no_expiration = (bool) $request->get_param( 'no_expiration' );
722
723        $body = $no_expiration
724            ? wp_json_encode( array( 'no_expiration' => true ), JSON_UNESCAPED_SLASHES )
725            : null;
726
727        $response = Client::wpcom_json_api_request_as_user(
728            sprintf(
729                '/sites/%d/memberships/comps/%d/%d',
730                (int) $blog_id,
731                $user_id,
732                $plan_id
733            ),
734            '2',
735            array(
736                'method'  => 'POST',
737                'headers' => array( 'Content-Type' => 'application/json' ),
738            ),
739            $body,
740            'wpcom'
741        );
742
743        if ( is_wp_error( $response ) ) {
744            return $response;
745        }
746
747        $status = (int) wp_remote_retrieve_response_code( $response );
748        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
749
750        // The Memberships API can report a failure either with an HTTP error status or with a 2xx
751        // response carrying an `error` payload (e.g. "User has already been comped this plan"), so
752        // treat both as failures and surface the upstream message rather than a generic one.
753        if ( $status >= 400 || ( is_array( $body ) && ! empty( $body['error'] ) ) ) {
754            return new WP_Error(
755                'subscribers_comp_failed',
756                $this->get_wpcom_error_message( $body, __( 'Could not comp the subscription.', 'jetpack' ) ),
757                array( 'status' => $status >= 400 ? $status : 400 )
758            );
759        }
760
761        return rest_ensure_response( $body );
762    }
763
764    /**
765     * POST /wpcom/v2/subscribers/remove-comp — revoke a complimentary subscription. Mirrors
766     * Calypso's `requestDeleteComp`, which DELETEs
767     * `/sites/{id}/memberships/comp/{compId}` (singular `comp`).
768     *
769     * @param WP_REST_Request $request Request.
770     * @return WP_REST_Response|WP_Error
771     */
772    public function remove_comp( $request ) {
773        $blog_id = Connection_Manager::get_site_id();
774
775        if ( is_wp_error( $blog_id ) ) {
776            return $blog_id;
777        }
778
779        $comp_id = (int) $request->get_param( 'comp_id' );
780
781        $response = Client::wpcom_json_api_request_as_user(
782            sprintf(
783                '/sites/%d/memberships/comp/%d',
784                (int) $blog_id,
785                $comp_id
786            ),
787            '2',
788            array( 'method' => 'DELETE' ),
789            null,
790            'wpcom'
791        );
792
793        if ( is_wp_error( $response ) ) {
794            return $response;
795        }
796
797        $status = (int) wp_remote_retrieve_response_code( $response );
798        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
799
800        // Mirror add_comp: the Memberships API can report a failure with an error status or with a
801        // 2xx response that carries an `error` payload, so treat both as failures.
802        if ( $status >= 400 || ( is_array( $body ) && ! empty( $body['error'] ) ) ) {
803            return new WP_Error(
804                'subscribers_remove_comp_failed',
805                $this->get_wpcom_error_message( $body, __( 'Could not remove the comp.', 'jetpack' ) ),
806                array( 'status' => $status >= 400 ? $status : 400 )
807            );
808        }
809
810        return rest_ensure_response( $body );
811    }
812
813    /**
814     * Extract the most specific human-readable error message from a wpcom Memberships API response
815     * body. The Memberships endpoints nest the reason under `error.message` (e.g. "User has already
816     * been comped this plan"); fall back to a top-level `message`, a string `error`, then the default.
817     *
818     * @param mixed  $body            Decoded response body.
819     * @param string $default_message Fallback used when the body carries no message.
820     * @return string Error message.
821     */
822    private function get_wpcom_error_message( $body, $default_message ) {
823        if ( is_array( $body ) ) {
824            if ( isset( $body['error']['message'] ) && is_string( $body['error']['message'] ) ) {
825                return $body['error']['message'];
826            }
827            if ( isset( $body['message'] ) && is_string( $body['message'] ) ) {
828                return $body['message'];
829            }
830            if ( isset( $body['error'] ) && is_string( $body['error'] ) ) {
831                return $body['error'];
832            }
833        }
834
835        return $default_message;
836    }
837
838    /**
839     * Helper: GET a wpcom v2 path on this site as the current user. Returns the parsed JSON
840     * response or a WP_Error.
841     *
842     * @param string $path Path under `/wpcom/v2`, including any query string.
843     * @return WP_REST_Response|WP_Error
844     */
845    private function wpcom_get( $path ) {
846        $response = Client::wpcom_json_api_request_as_user(
847            $path,
848            '2',
849            array( 'method' => 'GET' ),
850            null,
851            'wpcom'
852        );
853
854        if ( is_wp_error( $response ) ) {
855            return $response;
856        }
857
858        $status = (int) wp_remote_retrieve_response_code( $response );
859        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
860
861        if ( $status >= 400 ) {
862            return new WP_Error(
863                'wpcom_call_failed',
864                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'WP.com call failed.', 'jetpack' ),
865                array( 'status' => $status )
866            );
867        }
868
869        return rest_ensure_response( $body );
870    }
871}
872
873wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Subscribers_List' );