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