Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.82% covered (warning)
88.82%
1065 / 1199
40.54% covered (danger)
40.54%
15 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
Contact_Form_Endpoint
88.89% covered (warning)
88.89%
1064 / 1197
40.54% covered (danger)
40.54%
15 / 37
254.87
0.00% covered (danger)
0.00%
0 / 1
 get_supported_integrations
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 register_routes
99.51% covered (success)
99.51%
204 / 205
0.00% covered (danger)
0.00%
0 / 1
1
 get_source_array
6.90% covered (danger)
6.90%
2 / 29
0.00% covered (danger)
0.00%
0 / 1
16.91
 get_filters
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
1.01
 get_status_counts
78.26% covered (warning)
78.26%
36 / 46
0.00% covered (danger)
0.00%
0 / 1
11.03
 get_item_schema
100.00% covered (success)
100.00%
275 / 275
100.00% covered (success)
100.00%
1 / 1
3
 update_item
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 resend_email
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
9.26
 prepare_item_for_response
98.33% covered (success)
98.33%
59 / 60
0.00% covered (danger)
0.00%
0 / 1
27
 get_items
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
4.37
 modify_query_for_invalid_ids
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 prepare_items_query
45.83% covered (danger)
45.83%
11 / 24
0.00% covered (danger)
0.00%
0 / 1
18.17
 join_source_meta
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 filter_by_source_id
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_collection_params
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
1
 bulk_actions
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 get_bulk_scope_args
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
1
 fetch_bulk_scope_batch
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
12
 maybe_attach_source_filter_hooks
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 remove_source_filter_hooks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 delete_posts_by_status
96.30% covered (success)
96.30%
52 / 54
0.00% covered (danger)
0.00%
0 / 1
20
 bulk_action_mark_as_spam
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 bulk_action_mark_as_not_spam
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_items_permissions_check
36.36% covered (danger)
36.36%
4 / 11
0.00% covered (danger)
0.00%
0 / 1
8.12
 delete_items_permissions_check
36.36% covered (danger)
36.36%
4 / 11
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_item_permissions_check
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
5.67
 get_integration_metadata_fields
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 get_integration
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 get_single_integration_status
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 get_all_integrations_status
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_integrations_metadata
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_service_status
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 get_plugin_status
64.00% covered (warning)
64.00%
32 / 50
0.00% covered (danger)
0.00%
0 / 1
38.66
 disable_integration
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 update_read_status
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
4.14
 dismiss_classic_forms_notice
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_forms_config
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Contact_Form_Endpoint class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\External_Connections;
11use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
12use Automattic\Jetpack\Forms\Integrations\Built_In_Integrations;
13use Automattic\Jetpack\Forms\Integrations\Integration_Registry;
14use Automattic\Jetpack\Forms\Jetpack_Forms;
15use Automattic\Jetpack\Forms\Service\MailPoet_Integration;
16use Automattic\Jetpack\Redirect;
17use Automattic\Jetpack\Status;
18use WP_Error;
19use WP_Query;
20use WP_REST_Request;
21use WP_REST_Response;
22
23if ( ! defined( 'ABSPATH' ) ) {
24    exit( 0 );
25}
26
27/**
28 * Class Contact_Form_Endpoint
29 * Used as 'rest_controller_class' parameter when 'feedback' post type is
30 * registered in \Automattic\Jetpack\Forms\ContactForm\Contact_Form.
31 */
32class Contact_Form_Endpoint extends \WP_REST_Posts_Controller {
33
34    /**
35     * Seconds a chunked bulk delete may run before it stops and reports `has_more`.
36     *
37     * Kept well under the 120s gateway timeout measured on Atomic.
38     *
39     * @var int
40     */
41    const BULK_DELETE_TIME_BUDGET = 20;
42
43    /**
44     * Temporary storage for the source filter ID used in query modifications.
45     *
46     * @var int|null
47     */
48    private $temp_source_filter_id;
49
50    /**
51     * Cached SQL fragments for source filtering, to avoid recomputing per filter hook.
52     *
53     * @var array{join: string, where: string}|null
54     */
55    private $temp_source_filter_sql;
56
57    /**
58     * Get filtered list of supported integrations
59     *
60     * @return array Filtered list of supported integrations
61     */
62    private function get_supported_integrations() {
63        Built_In_Integrations::register();
64
65        $supported_integrations = Integration_Registry::available();
66
67        /**
68         * Filters the list of supported integrations available in Jetpack Forms.
69         *
70         * Prefer jetpack_forms_register_integration() for adding an integration: it accepts
71         * behavior and UI as well as the presentation keys below, and does not depend on the
72         * filter running at the right moment. This filter remains supported for removing or
73         * adjusting integrations.
74         *
75         * @since 6.4.0
76         *
77         * @param array $integrations Associative array of integration configurations keyed by slug.
78         *                            Each configuration supports the following keys:
79         *                            - type (string)                  : 'plugin' or 'service'.
80         *                            - file (string|null)             : Plugin file path for plugins; null for services.
81         *                            - settings_url (string|null)     : Relative admin URL for settings, or null.
82         *                            - marketing_redirect_slug (string|null) : Redirect slug for marketing links, or null.
83         *                            - title (string)                 : Default UI title for the integration.
84         *                            - subtitle (string)              : Default UI subtitle/description for the integration.
85         *                            - active_tooltip (string)        : Tooltip copy for when the integration is active/connected.
86         *                            - enabled_by_default (bool)      : Whether the integration is enabled by default on new forms.
87         *                            - icon_url (string|null)         : Absolute URL to an icon to display in the UI.
88         */
89        $supported_integrations = apply_filters( 'jetpack_forms_supported_integrations', $supported_integrations );
90
91        if ( ! is_array( $supported_integrations ) ) {
92            return array();
93        }
94
95        // An integration added through the filter supplies only the presentation keys, so fill
96        // in the rest and let every consumer read the same shape.
97        foreach ( $supported_integrations as $slug => $config ) {
98            $supported_integrations[ $slug ] = Integration_Registry::normalize( (array) $config );
99        }
100
101        // Registration order carries no meaning, so present them in a stable, predictable one.
102        uasort(
103            $supported_integrations,
104            static function ( $a, $b ) {
105                return strnatcasecmp( $a['title'], $b['title'] );
106            }
107        );
108
109        return $supported_integrations;
110    }
111
112    /**
113     * Registers the REST routes.
114     *
115     * @access public
116     */
117    public function register_routes() {
118        parent::register_routes();
119        register_rest_route(
120            $this->namespace,
121            $this->rest_base . '/filters',
122            array(
123                'methods'             => \WP_REST_Server::READABLE,
124                'callback'            => array( $this, 'get_filters' ),
125                'permission_callback' => array( $this, 'get_items_permissions_check' ),
126            )
127        );
128
129        // Register integrations routes
130        register_rest_route(
131            $this->namespace,
132            $this->rest_base . '/integrations',
133            array(
134                'methods'             => \WP_REST_Server::READABLE,
135                'callback'            => array( $this, 'get_all_integrations_status' ),
136                'permission_callback' => array( $this, 'get_items_permissions_check' ),
137                'args'                => array(
138                    'version' => array(
139                        'type'              => 'integer',
140                        'default'           => 1,
141                        'sanitize_callback' => 'absint',
142                        'validate_callback' => function ( $param ) {
143                            $version = absint( $param );
144                            return in_array( $version, array( 1, 2 ), true );
145                        },
146                    ),
147                ),
148            )
149        );
150
151        register_rest_route(
152            $this->namespace,
153            $this->rest_base . '/integrations-metadata',
154            array(
155                'methods'             => \WP_REST_Server::READABLE,
156                'callback'            => array( $this, 'get_integrations_metadata' ),
157                'permission_callback' => array( $this, 'get_items_permissions_check' ),
158            )
159        );
160
161        register_rest_route(
162            $this->namespace,
163            $this->rest_base . '/integrations/(?P<slug>[\w-]+(?:/[\w-]+)?)',
164            array(
165                'methods'             => \WP_REST_Server::READABLE,
166                'callback'            => array( $this, 'get_single_integration_status' ),
167                'permission_callback' => array( $this, 'get_items_permissions_check' ),
168                'args'                => array(
169                    'slug' => array(
170                        'type'              => 'string',
171                        'required'          => true,
172                        'sanitize_callback' => 'sanitize_text_field',
173                        'validate_callback' => function ( $param ) {
174                            return isset( $this->get_supported_integrations()[ $param ] );
175                        },
176                    ),
177                ),
178            )
179        );
180
181        register_rest_route(
182            $this->namespace,
183            $this->rest_base . '/integrations/(?P<slug>[\w-]+(?:/[\w-]+)?)',
184            array(
185                'methods'             => \WP_REST_Server::DELETABLE,
186                'callback'            => array( $this, 'disable_integration' ),
187                'permission_callback' => array( $this, 'get_items_permissions_check' ),
188                'args'                => array(
189                    'slug' => array(
190                        'type'              => 'string',
191                        'required'          => true,
192                        'sanitize_callback' => 'sanitize_text_field',
193                        'validate_callback' => function ( $param ) {
194                            return isset( $this->get_supported_integrations()[ $param ] );
195                        },
196                    ),
197                ),
198            )
199        );
200
201        register_rest_route(
202            $this->namespace,
203            $this->rest_base . '/bulk_actions',
204            array(
205                'methods'             => \WP_REST_Server::CREATABLE,
206                'callback'            => array( $this, 'bulk_actions' ),
207                'permission_callback' => array( $this, 'get_items_permissions_check' ),
208                'args'                => array(
209                    'action'   => array(
210                        'type'     => 'string',
211                        'enum'     => array(
212                            'mark_as_spam',
213                            'mark_as_not_spam',
214                        ),
215                        'required' => true,
216                    ),
217                    'post_ids' => array(
218                        'type'     => 'array',
219                        'items'    => array( 'type' => 'integer' ),
220                        'required' => true,
221                    ),
222                ),
223            )
224        );
225
226        register_rest_route(
227            $this->namespace,
228            $this->rest_base . '/trash',
229            array(
230                'methods'             => \WP_REST_Server::DELETABLE,
231                'callback'            => array( $this, 'delete_posts_by_status' ),
232                'permission_callback' => array( $this, 'delete_items_permissions_check' ),
233                'args'                => $this->get_bulk_scope_args( array( 'trash', 'spam' ), 'trash' ),
234            )
235        );
236
237        // Forms config endpoint.
238        register_rest_route(
239            $this->namespace,
240            $this->rest_base . '/config',
241            array(
242                'methods'             => \WP_REST_Server::READABLE,
243                'permission_callback' => array( $this, 'get_items_permissions_check' ),
244                'callback'            => array( $this, 'get_forms_config' ),
245            )
246        );
247
248        // Mark feedback as read/unread endpoint.
249        register_rest_route(
250            $this->namespace,
251            $this->rest_base . '/(?P<id>\d+)/read',
252            array(
253                'methods'             => \WP_REST_Server::CREATABLE,
254                'callback'            => array( $this, 'update_read_status' ),
255                'permission_callback' => array( $this, 'update_item_permissions_check' ),
256                'args'                => array(
257                    'id'        => array(
258                        'type'              => 'integer',
259                        'required'          => true,
260                        'sanitize_callback' => 'absint',
261                    ),
262                    'is_unread' => array(
263                        'type'              => 'boolean',
264                        'required'          => true,
265                        'sanitize_callback' => 'rest_sanitize_boolean',
266                    ),
267                ),
268            )
269        );
270
271        // Dismiss the classic forms notice.
272        register_rest_route(
273            $this->namespace,
274            $this->rest_base . '/dismiss-classic-forms-notice',
275            array(
276                'methods'             => \WP_REST_Server::CREATABLE,
277                'callback'            => array( $this, 'dismiss_classic_forms_notice' ),
278                'permission_callback' => array( $this, 'get_items_permissions_check' ),
279            )
280        );
281
282        // Get optimized status counts.
283        register_rest_route(
284            $this->namespace,
285            $this->rest_base . '/counts',
286            array(
287                'methods'             => \WP_REST_Server::READABLE,
288                'permission_callback' => array( $this, 'get_items_permissions_check' ),
289                'callback'            => array( $this, 'get_status_counts' ),
290                'args'                => array(
291                    'search'    => array(
292                        'description'       => 'Limit results to those matching a string.',
293                        'type'              => 'string',
294                        'sanitize_callback' => 'sanitize_text_field',
295                        'validate_callback' => 'rest_validate_request_arg',
296                    ),
297                    'parent'    => array(
298                        'description'       => 'Limit results to those of a specific parent ID.',
299                        'type'              => 'integer',
300                        'sanitize_callback' => 'absint',
301                        'validate_callback' => 'rest_validate_request_arg',
302                    ),
303                    'before'    => array(
304                        'description'       => 'Limit results to feedback published before a given ISO8601 compliant date.',
305                        'type'              => 'string',
306                        'format'            => 'date-time',
307                        'sanitize_callback' => 'sanitize_text_field',
308                        'validate_callback' => 'rest_validate_request_arg',
309                    ),
310                    'after'     => array(
311                        'description'       => 'Limit results to feedback published after a given ISO8601 compliant date.',
312                        'type'              => 'string',
313                        'format'            => 'date-time',
314                        'sanitize_callback' => 'sanitize_text_field',
315                        'validate_callback' => 'rest_validate_request_arg',
316                    ),
317                    'is_unread' => array(
318                        'description'       => 'Limit results to read or unread feedback items.',
319                        'type'              => 'boolean',
320                        'sanitize_callback' => 'rest_sanitize_boolean',
321                        'validate_callback' => 'rest_validate_request_arg',
322                    ),
323                    'is_test'   => array(
324                        'description'       => 'Limit results to test responses or exclude them.',
325                        'type'              => 'boolean',
326                        'sanitize_callback' => 'rest_sanitize_boolean',
327                        'validate_callback' => 'rest_validate_request_arg',
328                    ),
329                    'source'    => array(
330                        'description'       => 'Limit results to feedback submitted from a specific source post ID.',
331                        'type'              => 'integer',
332                        'sanitize_callback' => 'absint',
333                        'validate_callback' => 'rest_validate_request_arg',
334                    ),
335                ),
336            )
337        );
338    }
339    /**
340     * Get source array from post IDs
341     *
342     * @param array $post_ids Array of post IDs.
343     *
344     * @return array Array of sources.
345     */
346    private static function get_source_array( $post_ids ) {
347        if ( empty( $post_ids ) ) {
348            return array();
349        }
350
351        $source_query = new WP_Query(
352            array(
353                'post__in'       => $post_ids,
354                'post_status'    => array( 'publish', 'draft', 'pending', 'future', 'private', 'inherit', 'trash' ),
355                'post_type'      => 'any',
356                'orderby'        => 'post_title',
357                'order'          => 'ASC',
358                'posts_per_page' => count( $post_ids ), // Retrieve all in the post_ids array but no more than that.
359            )
360        );
361
362        return array_map(
363            static function ( $post ) {
364                $permalink = get_permalink( $post->ID );
365                if ( $permalink === false ) {
366                    $permalink = '';
367                }
368                $status = get_post_status( $post );
369                $title  = get_the_title( $post->ID );
370                if ( 'trash' === $status ) {
371                    $title = sprintf( /* translators: %s: post title */ __( '(trashed) %s', 'jetpack-forms' ), $title );
372                }
373                return array(
374                    'id'    => $post->ID,
375                    'title' => $title,
376                    'url'   => $permalink,
377                );
378            },
379            $source_query->posts
380        );
381    }
382
383    /**
384     * Retrieves all distinct sources (posts) and all the distinct available dates that
385     * any feedback was received, in order to be used as filters in the list.
386     *
387     * @return WP_REST_Response Response object on success.
388     */
389    public function get_filters() {
390        global $wpdb;
391        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
392        $months = $wpdb->get_results(
393            "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month
394            FROM $wpdb->posts
395            WHERE post_type = 'feedback'
396            ORDER BY post_date DESC"
397        );
398
399        $source_ids = Feedback::get_all_source_post_ids();
400        // phpcs:enable
401
402        return rest_ensure_response(
403            array(
404                'date'   => array_map(
405                    static function ( $row ) {
406                        return array(
407                            'month' => (int) $row->month,
408                            'year'  => (int) $row->year,
409                        );
410                    },
411                    $months
412                ),
413                'source' => self::get_source_array( $source_ids ),
414            )
415        );
416    }
417
418    /**
419     * Retrieves status counts for inbox, spam, and trash.
420     *
421     * @param WP_REST_Request $request Full data about the request.
422     * @return WP_REST_Response Response object on success.
423     */
424    public function get_status_counts( $request ) {
425        global $wpdb;
426
427        $search    = $request->get_param( 'search' );
428        $parent    = $request->get_param( 'parent' );
429        $source    = $request->get_param( 'source' );
430        $before    = $request->get_param( 'before' );
431        $after     = $request->get_param( 'after' );
432        $is_unread = $request->get_param( 'is_unread' );
433        $is_test   = $request->get_param( 'is_test' );
434
435        $join_clause      = '';
436        $where_conditions = array( $wpdb->prepare( "{$wpdb->posts}.post_type = %s", 'feedback' ) );
437
438        if ( ! empty( $search ) ) {
439            $search_like        = '%' . $wpdb->esc_like( $search ) . '%';
440            $where_conditions[] = $wpdb->prepare( "({$wpdb->posts}.post_title LIKE %s OR {$wpdb->posts}.post_content LIKE %s)", $search_like, $search_like );
441        }
442
443        if ( ! empty( $parent ) ) {
444            $where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.post_parent = %d", $parent );
445        }
446
447        if ( ! empty( $source ) ) {
448            $source_sql         = Feedback::get_source_filter_sql( absint( $source ) );
449            $join_clause       .= $source_sql['join'];
450            $where_conditions[] = $source_sql['where'];
451        }
452
453        if ( ! empty( $before ) ) {
454            $where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.post_date <= %s", $before );
455        }
456
457        if ( ! empty( $after ) ) {
458            $where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.post_date >= %s", $after );
459        }
460
461        if ( null !== $is_unread ) {
462            $comment_status     = $is_unread ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
463            $where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.comment_status = %s", $comment_status );
464        }
465
466        if ( null !== $is_test ) {
467            $is_test_meta_key   = esc_sql( Feedback::IS_TEST_META_KEY );
468            $join_clause       .= " LEFT JOIN {$wpdb->postmeta} AS is_test_meta ON ({$wpdb->posts}.ID = is_test_meta.post_id AND is_test_meta.meta_key = '{$is_test_meta_key}')";
469            $where_conditions[] = $is_test ? "is_test_meta.meta_value = '1'" : 'is_test_meta.meta_id IS NULL';
470        }
471
472        $where_clause = implode( ' AND ', $where_conditions );
473
474        // Execute single query with CASE statements for all status counts.
475        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
476        $counts = $wpdb->get_row(
477            "SELECT
478            SUM(CASE WHEN {$wpdb->posts}.post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox,
479            SUM(CASE WHEN {$wpdb->posts}.post_status = 'spam' THEN 1 ELSE 0 END) as spam,
480            SUM(CASE WHEN {$wpdb->posts}.post_status = 'trash' THEN 1 ELSE 0 END) as trash
481            FROM {$wpdb->posts}
482            {$join_clause}
483            WHERE {$where_clause}",
484            ARRAY_A
485        );
486        // phpcs:enable
487
488        $result = array(
489            'inbox' => (int) ( $counts['inbox'] ?? 0 ),
490            'spam'  => (int) ( $counts['spam'] ?? 0 ),
491            'trash' => (int) ( $counts['trash'] ?? 0 ),
492        );
493
494        return rest_ensure_response( $result );
495    }
496
497    /**
498     * Adds the additional fields to the item's schema.
499     *
500     * @return array Item schema as an array.
501     */
502    public function get_item_schema() {
503        $schema = parent::get_item_schema();
504
505        // Remove fields that are not relevant to feedback.
506        foreach ( array( 'link', 'password', 'template', 'title', 'content', 'excerpt' ) as $key ) {
507            if ( isset( $schema['properties'][ $key ] ) ) {
508                unset( $schema['properties'][ $key ] );
509            }
510        }
511
512        $schema['properties']['parent'] = array(
513            'description' => __( 'The ID for the parent of the post. This refers to the post/page where the feedback was created.', 'jetpack-forms' ),
514            'type'        => 'integer',
515            'context'     => array( 'view', 'edit', 'embed' ),
516            'readonly'    => true,
517        );
518
519        $schema['properties']['uid'] = array(
520            'description' => __( 'Unique identifier for the form response.', 'jetpack-forms' ),
521            'type'        => 'string',
522            'context'     => array( 'view', 'edit', 'embed' ),
523            'arg_options' => array(
524                'sanitize_callback' => 'sanitize_text_field',
525            ),
526            'readonly'    => true,
527        );
528
529        $schema['properties']['author_name'] = array(
530            'description' => __( 'The name of the person who submitted the form.', 'jetpack-forms' ),
531            'type'        => 'string',
532            'context'     => array( 'view', 'edit', 'embed' ),
533            'arg_options' => array(
534                'sanitize_callback' => 'sanitize_text_field',
535            ),
536            'readonly'    => true,
537        );
538
539        $schema['properties']['author_display_name'] = array(
540            'description' => __( 'The display name of the person who submitted the form. Either the name or the email if the name is not set.', 'jetpack-forms' ),
541            'type'        => 'string',
542            'context'     => array( 'view', 'edit', 'embed' ),
543            'arg_options' => array(
544                'sanitize_callback' => 'sanitize_text_field',
545            ),
546            'readonly'    => true,
547        );
548
549        $schema['properties']['author_email'] = array(
550            'description' => __( 'The email address of the person who submitted the form.', 'jetpack-forms' ),
551            'type'        => 'string',
552            'context'     => array( 'view', 'edit', 'embed' ),
553            'arg_options' => array(
554                'sanitize_callback' => 'sanitize_text_field',
555            ),
556            'readonly'    => true,
557        );
558
559        $schema['properties']['author_url'] = array(
560            'description' => __( 'The website URL of the person who submitted the form.', 'jetpack-forms' ),
561            'type'        => 'string',
562            'context'     => array( 'view', 'edit', 'embed' ),
563            'arg_options' => array(
564                'sanitize_callback' => 'sanitize_text_field',
565            ),
566            'readonly'    => true,
567        );
568
569        $schema['properties']['author_avatar'] = array(
570            'description' => __( 'The URL of the avatar image for the person who submitted the form.', 'jetpack-forms' ),
571            'type'        => 'string',
572            'context'     => array( 'view', 'edit', 'embed' ),
573            'arg_options' => array(
574                'sanitize_callback' => 'sanitize_text_field',
575            ),
576            'readonly'    => true,
577        );
578
579        $schema['properties']['email_marketing_consent'] = array(
580            'description' => __( 'Whether the person consented to email marketing when submitting the form.', 'jetpack-forms' ),
581            'type'        => 'string',
582            'context'     => array( 'view', 'edit', 'embed' ),
583            'arg_options' => array(
584                'sanitize_callback' => 'sanitize_text_field',
585            ),
586            'readonly'    => true,
587        );
588
589        $schema['properties']['ip'] = array(
590            'description' => __( 'The IP address from which the form was submitted.', 'jetpack-forms' ),
591            'type'        => 'string',
592            'context'     => array( 'view', 'edit', 'embed' ),
593            'arg_options' => array(
594                'sanitize_callback' => 'sanitize_text_field',
595            ),
596            'readonly'    => true,
597        );
598
599        $schema['properties']['country_code'] = array(
600            'description' => __( 'The country code derived from the IP address.', 'jetpack-forms' ),
601            'type'        => 'string',
602            'context'     => array( 'view', 'edit', 'embed' ),
603            'arg_options' => array(
604                'sanitize_callback' => 'sanitize_text_field',
605            ),
606            'readonly'    => true,
607        );
608
609        $schema['properties']['form_fill_duration'] = array(
610            'description' => __( 'The duration in seconds from first user interaction to form submission. Null when the duration is unknown, such as for submissions predating this feature.', 'jetpack-forms' ),
611            'type'        => array( 'integer', 'null' ),
612            'context'     => array( 'view', 'edit', 'embed' ),
613            // No sanitize_callback: the field is readonly and sanitized on storage, and
614            // `absint` would coerce a legitimate null into 0.
615            'readonly'    => true,
616        );
617
618        $schema['properties']['browser'] = array(
619            'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ),
620            'type'        => 'string',
621            'context'     => array( 'view', 'edit', 'embed' ),
622            'arg_options' => array(
623                'sanitize_callback' => 'sanitize_text_field',
624            ),
625            'readonly'    => true,
626        );
627
628        $schema['properties']['logged_in_user'] = array(
629            'description' => __( 'The logged-in user who submitted the form, if any.', 'jetpack-forms' ),
630            'type'        => array( 'object', 'null' ),
631            'context'     => array( 'view', 'edit', 'embed' ),
632            'properties'  => array(
633                'display_name' => array(
634                    'type'        => 'string',
635                    'description' => __( 'The display name of the logged-in user.', 'jetpack-forms' ),
636                    'arg_options' => array(
637                        'sanitize_callback' => 'sanitize_text_field',
638                    ),
639                ),
640                'username'     => array(
641                    'type'        => 'string',
642                    'description' => __( 'The username of the logged-in user.', 'jetpack-forms' ),
643                    'arg_options' => array(
644                        'sanitize_callback' => 'sanitize_text_field',
645                    ),
646                ),
647                'id'           => array(
648                    'type'        => 'integer',
649                    'description' => __( 'The ID of the logged-in user.', 'jetpack-forms' ),
650                    'arg_options' => array(
651                        'sanitize_callback' => 'absint',
652                    ),
653                ),
654            ),
655            'readonly'    => true,
656        );
657
658        $schema['properties']['entry_title'] = array(
659            'description' => __( 'The title of the page or post where the form was submitted.', 'jetpack-forms' ),
660            'type'        => 'string',
661            'context'     => array( 'view', 'edit', 'embed' ),
662            'arg_options' => array(
663                'sanitize_callback' => 'sanitize_text_field',
664            ),
665            'readonly'    => true,
666        );
667
668        $schema['properties']['entry_permalink'] = array(
669            'description' => __( 'The URL of the page or post where the form was submitted.', 'jetpack-forms' ),
670            'type'        => 'string',
671            'context'     => array( 'view', 'edit', 'embed' ),
672            'arg_options' => array(
673                'sanitize_callback' => 'sanitize_text_field',
674            ),
675            'readonly'    => true,
676        );
677
678        $schema['properties']['form_id'] = array(
679            'description' => __( 'The ID of the jetpack_form post the response is tied to, or 0 for classic (embedded) forms.', 'jetpack-forms' ),
680            'type'        => 'integer',
681            'context'     => array( 'view', 'edit', 'embed' ),
682            'readonly'    => true,
683        );
684
685        $schema['properties']['edit_form_url'] = array(
686            'description' => __( 'The URL to edit the form.', 'jetpack-forms' ),
687            'type'        => 'string',
688            'context'     => array( 'view', 'edit', 'embed' ),
689            'arg_options' => array(
690                'sanitize_callback' => 'sanitize_text_field',
691            ),
692            'readonly'    => true,
693        );
694
695        $schema['properties']['subject'] = array(
696            'description' => __( 'The subject line of the form submission.', 'jetpack-forms' ),
697            'type'        => 'string',
698            'context'     => array( 'view', 'edit', 'embed' ),
699            'arg_options' => array(
700                'sanitize_callback' => 'sanitize_text_field',
701            ),
702            'readonly'    => true,
703        );
704
705        $schema['properties']['fields'] = array(
706            'description' => __( 'The custom form fields and their submitted values.', 'jetpack-forms' ),
707            'type'        => 'object',
708            'context'     => array( 'view', 'edit', 'embed' ),
709            'arg_options' => array(
710                'sanitize_callback' => 'sanitize_text_field',
711            ),
712            'properties'  => array(
713                'files' => array(
714                    'type'       => 'object',
715                    'properties' => array(
716                        'field_id' => array(
717                            'type'        => 'string',
718                            'arg_options' => array(
719                                'sanitize_callback' => 'sanitize_text_field',
720                            ),
721                        ),
722                        'files'    => array(
723                            'type'  => 'array',
724                            'items' => array(
725                                'type'       => 'object',
726                                'properties' => array(
727                                    'file_id'        => array(
728                                        'type'        => 'integer',
729                                        'arg_options' => array(
730                                            'sanitize_callback' => 'sanitize_text_field',
731                                        ),
732                                    ),
733                                    'name'           => array(
734                                        'type'        => 'string',
735                                        'arg_options' => array(
736                                            'sanitize_callback' => 'sanitize_text_field',
737                                        ),
738                                    ),
739                                    'size'           => array(
740                                        'type'        => 'string',
741                                        'arg_options' => array(
742                                            'sanitize_callback' => 'sanitize_text_field',
743                                        ),
744                                    ),
745                                    'url'            => array(
746                                        'type'        => 'string',
747                                        'arg_options' => array(
748                                            'sanitize_callback' => 'esc_url_raw',
749                                        ),
750                                    ),
751                                    'is_previewable' => array(
752                                        'type'        => 'boolean',
753                                        'arg_options' => array(
754                                            'sanitize_callback' => 'rest_sanitize_boolean',
755                                        ),
756                                    ),
757                                ),
758                            ),
759                        ),
760                    ),
761                ),
762            ),
763            'readonly'    => true,
764        );
765
766        $schema['properties']['has_file'] = array(
767            'description' => __( 'Does the form response contain a file.', 'jetpack-forms' ),
768            'type'        => 'boolean',
769            'context'     => array( 'view', 'edit', 'embed' ),
770            'arg_options' => array(
771                'sanitize_callback' => 'booleanval',
772            ),
773            'readonly'    => true,
774        );
775
776        $schema['properties']['is_unread'] = array(
777            'description' => __( 'Whether the form response is unread.', 'jetpack-forms' ),
778            'type'        => 'boolean',
779            'context'     => array( 'view', 'edit', 'embed' ),
780            'arg_options' => array(
781                'sanitize_callback' => 'rest_sanitize_boolean',
782            ),
783            'readonly'    => true,
784        );
785
786        $schema['properties']['is_test'] = array(
787            'description' => __( 'Whether the form response was submitted from a form preview (test response).', 'jetpack-forms' ),
788            'type'        => 'boolean',
789            'context'     => array( 'view', 'edit', 'embed' ),
790            'arg_options' => array(
791                'sanitize_callback' => 'rest_sanitize_boolean',
792            ),
793            'readonly'    => true,
794        );
795
796        $schema['properties']['preview_url'] = array(
797            'description' => __( 'URL to the form preview that produced this response, when the response is a test submission.', 'jetpack-forms' ),
798            'type'        => array( 'string', 'null' ),
799            'context'     => array( 'view', 'edit', 'embed' ),
800            'arg_options' => array(
801                'sanitize_callback' => 'esc_url_raw',
802            ),
803            'readonly'    => true,
804        );
805
806        $this->schema = $schema;
807
808        return $this->add_additional_fields_schema( $this->schema );
809    }
810
811    /**
812     * Updates the item.
813     * Overrides the parent method to resend the email when the item is updated from spam to publish.
814     *
815     * @param WP_REST_Request $request Request object.
816     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
817     */
818    public function update_item( $request ) {
819        $valid_check = parent::get_post( $request['id'] );
820        if ( is_wp_error( $valid_check ) ) {
821            return $valid_check;
822        }
823
824        $post_id         = $request['id'];
825        $previous_status = get_post_status( $post_id );
826        $updated_item    = parent::update_item( $request );
827
828        if ( ! is_wp_error( $updated_item ) && ! empty( $updated_item->data && ! empty( $updated_item->data['status'] ) ) ) {
829            if ( $previous_status === 'spam' && $updated_item->data['status'] === 'publish' ) {
830                // updated item is going from spam to inbox
831                $akismet_values = get_post_meta( $post_id, '_feedback_akismet_values', true );
832                /** This action is documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
833                do_action( 'contact_form_akismet', 'ham', $akismet_values );
834                $this->resend_email( $post_id );
835            }
836        }
837        return $updated_item;
838    }
839
840    /**
841     * Resends the email for a given post ID.
842     *
843     * @param int $post_id The ID of the post to resend the email for.
844     */
845    public function resend_email( $post_id ) {
846        $comment_author_email = false;
847        $reply_to_addr        = false;
848        $message              = '';
849        $to                   = false;
850        $headers              = false;
851        $blog_url             = wp_parse_url( site_url() );
852
853        // resend the original email
854        $email = get_post_meta( $post_id, '_feedback_email', true );
855
856        $response = Feedback::get( $post_id );
857        if ( ! $response ) {
858            return;
859        }
860
861        if ( ! empty( $response->get_author_email() ) ) {
862            $comment_author_email = $response->get_author_email();
863        }
864
865        if ( isset( $email['to'] ) ) {
866            $to = $email['to'];
867        }
868
869        if ( isset( $email['message'] ) ) {
870            $message = $email['message'];
871        }
872
873        if ( isset( $email['headers'] ) ) {
874            $headers = $email['headers'];
875        } else {
876            $headers = 'From: "' . $response->get_author() . '" <wordpress@' . $blog_url['host'] . ">\r\n";
877
878            if ( ! empty( $comment_author_email ) ) {
879                $reply_to_addr = $comment_author_email;
880            } elseif ( is_array( $to ) ) {
881                $reply_to_addr = $to[0];
882            }
883
884            if ( $reply_to_addr ) {
885                $headers .= 'Reply-To: "' . $response->get_author() . '" <' . $reply_to_addr . ">\r\n";
886            }
887
888            $headers .= 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"';
889        }
890        Contact_Form::wp_mail( $to, $response->get_subject(), $message, $headers );
891    }
892
893    /**
894     * Prepares the item for the REST response.
895     *
896     * @param object          $item    WP Cron event.
897     * @param WP_REST_Request $request Request object.
898     * @return WP_REST_Response Response object on success.
899     */
900    public function prepare_item_for_response( $item, $request ) {
901        $response = parent::prepare_item_for_response( $item, $request );
902        $data     = $response->get_data();
903        $fields   = $this->get_fields_for_response( $request );
904
905        $feedback_response = Feedback::get( $item->ID );
906        if ( ! $feedback_response ) {
907            return rest_ensure_response( $data );
908        }
909
910        // Lazily backfill source meta for old feedback that doesn't have it yet.
911        Feedback::maybe_backfill_source_meta( $item->ID, $feedback_response );
912
913        $data['date'] = get_the_date( 'c', $data['id'] );
914        if ( rest_is_field_included( 'uid', $fields ) ) {
915            $data['uid'] = $feedback_response->get_feedback_id();
916        }
917
918        if ( rest_is_field_included( 'author_name', $fields ) ) {
919            $data['author_name'] = $feedback_response->get_author_name();
920        }
921
922        if ( rest_is_field_included( 'author_display_name', $fields ) ) {
923            $data['author_display_name'] = $feedback_response->get_author();
924        }
925
926        if ( rest_is_field_included( 'author_email', $fields ) ) {
927            $data['author_email'] = $feedback_response->get_author_email();
928        }
929
930        if ( rest_is_field_included( 'author_url', $fields ) ) {
931            $data['author_url'] = $feedback_response->get_author_url();
932        }
933
934        if ( rest_is_field_included( 'author_avatar', $fields ) ) {
935            $data['author_avatar'] = $feedback_response->get_author_avatar();
936        }
937
938        if ( rest_is_field_included( 'email_marketing_consent', $fields ) ) {
939            $data['email_marketing_consent'] = $feedback_response->has_consent() ? '1' : '';
940        }
941
942        if ( rest_is_field_included( 'ip', $fields ) ) {
943            $data['ip'] = $feedback_response->get_ip_address();
944        }
945
946        if ( rest_is_field_included( 'country_code', $fields ) ) {
947            $data['country_code'] = $feedback_response->get_country_code();
948        }
949
950        if ( rest_is_field_included( 'form_fill_duration', $fields ) ) {
951            $data['form_fill_duration'] = $feedback_response->get_form_fill_duration();
952        }
953
954        if ( rest_is_field_included( 'browser', $fields ) ) {
955            $data['browser'] = $feedback_response->get_browser();
956        }
957
958        if ( rest_is_field_included( 'logged_in_user', $fields ) ) {
959            $data['logged_in_user'] = $feedback_response->get_logged_in_user();
960        }
961
962        if ( rest_is_field_included( 'entry_title', $fields ) ) {
963            $data['entry_title'] = $feedback_response->get_entry_title();
964        }
965
966        if ( rest_is_field_included( 'entry_permalink', $fields ) ) {
967            $data['entry_permalink'] = $feedback_response->get_entry_permalink();
968        }
969
970        if ( rest_is_field_included( 'form_id', $fields ) ) {
971            $data['form_id'] = (int) $feedback_response->get_form_id();
972        }
973
974        if ( rest_is_field_included( 'edit_form_url', $fields ) ) {
975            $data['edit_form_url'] = $feedback_response->get_edit_form_url();
976        }
977
978        if ( rest_is_field_included( 'subject', $fields ) ) {
979            $data['subject'] = $feedback_response->get_subject();
980        }
981
982        if ( rest_is_field_included( 'fields', $fields ) ) {
983            $fields_format  = $request->get_param( 'fields_format' ) ?? 'label-value';
984            $data['fields'] = $feedback_response->get_compiled_fields( 'api', $fields_format );
985        }
986
987        if ( rest_is_field_included( 'has_file', $fields ) ) {
988            $data['has_file'] = $feedback_response->has_file();
989        }
990
991        if ( rest_is_field_included( 'is_unread', $fields ) ) {
992            $data['is_unread'] = $feedback_response->is_unread();
993        }
994
995        if ( rest_is_field_included( 'is_test', $fields ) ) {
996            $data['is_test'] = $feedback_response->is_test();
997        }
998
999        if ( rest_is_field_included( 'preview_url', $fields ) ) {
1000            $preview_url = null;
1001            if ( $feedback_response->is_test() ) {
1002                $form_id = $feedback_response->get_form_id();
1003                if ( $form_id ) {
1004                    $preview_url = Form_Preview::generate_preview_url( (int) $form_id );
1005                }
1006            }
1007            $data['preview_url'] = $preview_url;
1008        }
1009
1010        $response->set_data( $data );
1011
1012        return rest_ensure_response( $response );
1013    }
1014
1015    /**
1016     * Retrieves a collection of feedback items.
1017     * Overrides parent to support invalid_ids with OR logic.
1018     *
1019     * @param WP_REST_Request $request Full details about the request.
1020     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
1021     */
1022    public function get_items( $request ) {
1023        $invalid_ids = $request->get_param( 'invalid_ids' );
1024
1025        // If we have invalid_ids, we need to modify the query with a WHERE clause
1026        if ( ! empty( $invalid_ids ) ) {
1027            add_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10, 2 );
1028            // Store invalid_ids temporarily so the filter can access them
1029            $this->temp_invalid_ids = $invalid_ids;
1030        }
1031
1032        $response = parent::get_items( $request );
1033
1034        // Clean up
1035        if ( ! empty( $invalid_ids ) ) {
1036            remove_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10 );
1037            unset( $this->temp_invalid_ids );
1038        }
1039        if ( ! empty( $this->temp_source_filter_id ) ) {
1040            remove_filter( 'posts_join', array( $this, 'join_source_meta' ), 10 );
1041            remove_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10 );
1042            $this->temp_source_filter_id  = null;
1043            $this->temp_source_filter_sql = null;
1044        }
1045
1046        return $response;
1047    }
1048
1049    /**
1050     * Modify the WHERE clause to include invalid_ids with OR logic.
1051     *
1052     * @param string   $where The WHERE clause.
1053     * @param WP_Query $query The WP_Query instance.
1054     * @return string Modified WHERE clause.
1055     */
1056    public function modify_query_for_invalid_ids( $where, $query ) {
1057        global $wpdb;
1058
1059        // Only modify our feedback queries
1060        if ( ! isset( $this->temp_invalid_ids ) || empty( $this->temp_invalid_ids ) ) {
1061            return $where;
1062        }
1063
1064        // Only modify if this is a feedback query
1065        $post_type = $query->get( 'post_type' );
1066        if ( $post_type !== 'feedback' ) {
1067            return $where;
1068        }
1069
1070        $invalid_ids_sql = implode( ',', array_map( 'absint', $this->temp_invalid_ids ) );
1071
1072        // Wrap the existing WHERE in parentheses before appending the OR branch, and re-assert
1073        // post_type='feedback' on the OR side. SQL AND binds tighter than OR; without these
1074        // guards the appended " OR ID IN (...)" would collapse the existing post_type filter
1075        // and let any post ID (regardless of type or status) be returned by the endpoint.
1076        $where = "({$where}) OR ({$wpdb->posts}.ID IN ({$invalid_ids_sql}) AND {$wpdb->posts}.post_type = 'feedback')";
1077
1078        return $where;
1079    }
1080
1081    /**
1082     * Filters the query arguments for the feedback collection.
1083     *
1084     * @param array           $args    Key value array of query var to query value.
1085     * @param WP_REST_Request $request The request used.
1086     * @return array Modified query arguments.
1087     */
1088    protected function prepare_items_query( $args = array(), $request = null ) {
1089        $args = parent::prepare_items_query( $args, $request );
1090
1091        if ( isset( $request['is_unread'] ) ) {
1092            $args['comment_status'] = $request['is_unread'] ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
1093        }
1094
1095        // Filter by source post ID using meta (with fallback to post_parent for old data).
1096        $source = $request->get_param( 'source' );
1097        if ( ! empty( $source ) ) {
1098            $this->temp_source_filter_id  = absint( $source );
1099            $this->temp_source_filter_sql = Feedback::get_source_filter_sql( $this->temp_source_filter_id );
1100            add_filter( 'posts_join', array( $this, 'join_source_meta' ), 10, 2 );
1101            add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 );
1102        }
1103
1104        // Filter by test/non-test responses via the _feedback_is_test meta.
1105        $is_test = $request->get_param( 'is_test' );
1106        if ( null !== $is_test ) {
1107            $meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : array();
1108            if ( $is_test ) {
1109                $meta_query[] = array(
1110                    'key'     => Feedback::IS_TEST_META_KEY,
1111                    'value'   => '1',
1112                    'compare' => '=',
1113                );
1114            } else {
1115                $meta_query[] = array(
1116                    'key'     => Feedback::IS_TEST_META_KEY,
1117                    'compare' => 'NOT EXISTS',
1118                );
1119            }
1120            $args['meta_query'] = $meta_query;
1121        }
1122
1123        return $args;
1124    }
1125
1126    /**
1127     * Joins the postmeta table for source filtering.
1128     *
1129     * @param string   $join  The JOIN clause.
1130     * @param WP_Query $query The WP_Query instance.
1131     * @return string Modified JOIN clause.
1132     */
1133    public function join_source_meta( $join, $query ) {
1134        if ( empty( $this->temp_source_filter_sql ) || Feedback::POST_TYPE !== $query->get( 'post_type' ) ) {
1135            return $join;
1136        }
1137        return $join . $this->temp_source_filter_sql['join'];
1138    }
1139
1140    /**
1141     * Filters feedback by source post ID, using meta with fallback to post_parent for old data.
1142     *
1143     * @param string   $where The WHERE clause.
1144     * @param WP_Query $query The WP_Query instance.
1145     * @return string Modified WHERE clause.
1146     */
1147    public function filter_by_source_id( $where, $query ) {
1148        if ( empty( $this->temp_source_filter_sql ) || Feedback::POST_TYPE !== $query->get( 'post_type' ) ) {
1149            return $where;
1150        }
1151        return $where . ' AND ' . $this->temp_source_filter_sql['where'];
1152    }
1153
1154    /**
1155     * Retrieves the query params for the feedback collection.
1156     *
1157     * @return array Collection parameters.
1158     */
1159    public function get_collection_params() {
1160        $query_params = parent::get_collection_params();
1161
1162        // Add parent related query parameters since the `feedback` post type is not hierarchical, but
1163        // it uses the `parent` field to store the ID of the post/page where the feedback was created.
1164        $query_params['parent']         = array(
1165            'description' => __( 'Limit result set to items with particular parent IDs.', 'jetpack-forms' ),
1166            'type'        => 'array',
1167            'items'       => array(
1168                'type' => 'integer',
1169            ),
1170            'default'     => array(),
1171        );
1172        $query_params['parent_exclude'] = array(
1173            'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'jetpack-forms' ),
1174            'type'        => 'array',
1175            'items'       => array(
1176                'type' => 'integer',
1177            ),
1178            'default'     => array(),
1179        );
1180        $query_params['source']         = array(
1181            'description'       => __( 'Limit result set to feedback submitted from a particular source post ID.', 'jetpack-forms' ),
1182            'type'              => 'integer',
1183            'sanitize_callback' => 'absint',
1184            'validate_callback' => 'rest_validate_request_arg',
1185        );
1186        $query_params['is_unread']      = array(
1187            'description'       => __( 'Limit result set to read or unread feedback items.', 'jetpack-forms' ),
1188            'type'              => 'boolean',
1189            'sanitize_callback' => 'rest_sanitize_boolean',
1190            'validate_callback' => 'rest_validate_request_arg',
1191        );
1192        $query_params['is_test']        = array(
1193            'description'       => __( 'Limit result set to test responses (from form preview) or exclude them.', 'jetpack-forms' ),
1194            'type'              => 'boolean',
1195            'sanitize_callback' => 'rest_sanitize_boolean',
1196            'validate_callback' => 'rest_validate_request_arg',
1197        );
1198        $query_params['invalid_ids']    = array(
1199            'description'       => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ),
1200            'type'              => 'array',
1201            'items'             => array(
1202                'type' => 'integer',
1203            ),
1204            'default'           => array(),
1205            'sanitize_callback' => function ( $param ) {
1206                return array_map( 'absint', (array) $param );
1207            },
1208            'validate_callback' => 'rest_validate_request_arg',
1209        );
1210        $query_params['fields_format']  = array(
1211            'description'       => __( 'Format for the fields data in the response.', 'jetpack-forms' ),
1212            'type'              => 'string',
1213            'enum'              => array( 'label-value', 'collection' ),
1214            'default'           => 'label-value',
1215            'sanitize_callback' => 'sanitize_text_field',
1216            'validate_callback' => 'rest_validate_request_arg',
1217        );
1218        return $query_params;
1219    }
1220
1221    /**
1222     * Handles bulk actions for Jetpack Forms responses.
1223     *
1224     * @param WP_REST_Request $request The request sent to the WP REST API.
1225     *
1226     * @return WP_REST_Response A response object..
1227     */
1228    public function bulk_actions( $request ) {
1229        $action   = $request->get_param( 'action' );
1230        $post_ids = $request->get_param( 'post_ids' );
1231
1232        if ( $action && ! is_array( $post_ids ) ) {
1233            return new WP_REST_Response( array( 'error' => __( 'Bad request', 'jetpack-forms' ) ), 400 );
1234        }
1235
1236        switch ( $action ) {
1237            case 'mark_as_spam':
1238                return $this->bulk_action_mark_as_spam( $post_ids );
1239
1240            case 'mark_as_not_spam':
1241                return $this->bulk_action_mark_as_not_spam( $post_ids );
1242
1243            default:
1244                return new WP_REST_Response( array( 'error' => __( 'Bad request', 'jetpack-forms' ) ), 400 );
1245        }
1246    }
1247
1248    /**
1249     * Builds the REST args for the scope-aware `/trash` endpoint.
1250     *
1251     * @param string[] $allowed_statuses Statuses that may be operated on.
1252     * @param string   $default_status   Default status if none is provided.
1253     * @return array
1254     */
1255    protected function get_bulk_scope_args( $allowed_statuses, $default_status ) {
1256        return array(
1257            'status'    => array(
1258                'type'     => 'string',
1259                'enum'     => $allowed_statuses,
1260                'required' => false,
1261                'default'  => $default_status,
1262            ),
1263            'post_ids'  => array(
1264                'type'              => 'array',
1265                'items'             => array( 'type' => 'integer' ),
1266                'required'          => false,
1267                'sanitize_callback' => function ( $param ) {
1268                    $ids = array_map( 'intval', (array) $param );
1269                    return array_values(
1270                        array_filter(
1271                            $ids,
1272                            function ( $id ) {
1273                                return $id > 0;
1274                            }
1275                        )
1276                    );
1277                },
1278            ),
1279            'search'    => array(
1280                'type'              => 'string',
1281                'required'          => false,
1282                'sanitize_callback' => 'sanitize_text_field',
1283            ),
1284            'parent'    => array(
1285                'type'              => 'integer',
1286                'required'          => false,
1287                'sanitize_callback' => 'absint',
1288            ),
1289            'source'    => array(
1290                'type'              => 'integer',
1291                'required'          => false,
1292                'sanitize_callback' => 'absint',
1293            ),
1294            'before'    => array(
1295                'type'              => 'string',
1296                'format'            => 'date-time',
1297                'required'          => false,
1298                'sanitize_callback' => 'sanitize_text_field',
1299            ),
1300            'after'     => array(
1301                'type'              => 'string',
1302                'format'            => 'date-time',
1303                'required'          => false,
1304                'sanitize_callback' => 'sanitize_text_field',
1305            ),
1306            'is_unread' => array(
1307                'type'              => 'boolean',
1308                'required'          => false,
1309                'sanitize_callback' => 'rest_sanitize_boolean',
1310            ),
1311            'is_test'   => array(
1312                'type'              => 'boolean',
1313                'required'          => false,
1314                'sanitize_callback' => 'rest_sanitize_boolean',
1315            ),
1316            'limit'     => array(
1317                'description' => __( 'Delete at most this many responses, then report whether more remain.', 'jetpack-forms' ),
1318                'type'        => 'integer',
1319                'minimum'     => 1,
1320                'maximum'     => 5000,
1321                'required'    => false,
1322            ),
1323        );
1324    }
1325
1326    /**
1327     * Fetches a batch of feedback IDs within the requested status, honoring the
1328     * bulk-scope params (explicit IDs or filter query). Source filtering is
1329     * applied via the existing `posts_join` / `posts_where` hooks.
1330     *
1331     * @param WP_REST_Request $request    The request.
1332     * @param string          $status     Post status to scope to (`spam` or `trash`).
1333     * @param int             $batch_size Max IDs to return.
1334     * @return int[] Feedback post IDs.
1335     */
1336    private function fetch_bulk_scope_batch( $request, $status, $batch_size ) {
1337        $post_ids_param = (array) $request->get_param( 'post_ids' );
1338        $has_explicit   = ! empty( $post_ids_param );
1339
1340        $query_args = array(
1341            'post_type'      => 'feedback',
1342            'post_status'    => $status,
1343            'posts_per_page' => $batch_size,
1344            'fields'         => 'ids',
1345        );
1346
1347        if ( $has_explicit ) {
1348            $query_args['post__in'] = $post_ids_param;
1349            $query_args['orderby']  = 'post__in';
1350        } else {
1351            $search = $request->get_param( 'search' );
1352            if ( is_string( $search ) && $search !== '' ) {
1353                $query_args['s'] = $search;
1354            }
1355
1356            $parent = $request->get_param( 'parent' );
1357            if ( is_numeric( $parent ) && (int) $parent > 0 ) {
1358                $query_args['post_parent'] = (int) $parent;
1359            }
1360
1361            $before = $request->get_param( 'before' );
1362            $after  = $request->get_param( 'after' );
1363            if ( ! empty( $before ) || ! empty( $after ) ) {
1364                $query_args['date_query'] = array_filter(
1365                    array(
1366                        'before' => $before,
1367                        'after'  => $after,
1368                    )
1369                );
1370            }
1371
1372            $is_unread = $request->get_param( 'is_unread' );
1373            if ( null !== $is_unread ) {
1374                $query_args['comment_status'] = $is_unread ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
1375            }
1376
1377            $is_test = $request->get_param( 'is_test' );
1378            if ( null !== $is_test ) {
1379                $is_test_clause = array(
1380                    'key'     => Feedback::IS_TEST_META_KEY,
1381                    'compare' => 'NOT EXISTS',
1382                );
1383                if ( $is_test ) {
1384                    $is_test_clause = array(
1385                        'key'     => Feedback::IS_TEST_META_KEY,
1386                        'value'   => '1',
1387                        'compare' => '=',
1388                    );
1389                }
1390                $query_args['meta_query'] = array( $is_test_clause );
1391            }
1392        }
1393
1394        $query = new \WP_Query( $query_args );
1395        return array_map( 'intval', $query->get_posts() );
1396    }
1397
1398    /**
1399     * Attaches source-filter hooks if the request includes a `source` param and
1400     * no explicit `post_ids`. Returns true if hooks were attached (caller must
1401     * call `remove_source_filter_hooks()` after the loop).
1402     *
1403     * @param WP_REST_Request $request The request.
1404     * @return bool Whether hooks were attached.
1405     */
1406    private function maybe_attach_source_filter_hooks( $request ) {
1407        if ( ! empty( (array) $request->get_param( 'post_ids' ) ) ) {
1408            return false;
1409        }
1410        $source = $request->get_param( 'source' );
1411        $source = is_numeric( $source ) ? absint( $source ) : 0;
1412        if ( $source <= 0 ) {
1413            return false;
1414        }
1415        $this->temp_source_filter_id  = $source;
1416        $this->temp_source_filter_sql = Feedback::get_source_filter_sql( $source );
1417        add_filter( 'posts_join', array( $this, 'join_source_meta' ), 10, 2 );
1418        add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 );
1419        return true;
1420    }
1421
1422    /**
1423     * Removes source-filter hooks attached by `maybe_attach_source_filter_hooks`.
1424     */
1425    private function remove_source_filter_hooks() {
1426        remove_filter( 'posts_join', array( $this, 'join_source_meta' ), 10 );
1427        remove_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10 );
1428        $this->temp_source_filter_id  = null;
1429        $this->temp_source_filter_sql = null;
1430    }
1431
1432    /**
1433     * Handles permanently deleting Jetpack Forms responses by status.
1434     *
1435     * Scope resolution:
1436     *  - With `post_ids` â†’ deletes those IDs (filtered to rows actually in `status`).
1437     *  - Else with any filter (search / parent / source / before / after / is_unread / is_test) â†’
1438     *    deletes every response in `status` matching those filters.
1439     *  - Else â†’ deletes every response in `status` (legacy behavior).
1440     *
1441     * With `limit`, at most that many are deleted (stopping early after
1442     * BULK_DELETE_TIME_BUDGET seconds) and `has_more` tells the caller to repeat the request.
1443     *
1444     * The operation is non-reversible; restricted to statuses spam and trash via the
1445     * route args enum, and re-checked here for defense in depth.
1446     *
1447     * @param WP_REST_Request $request The request sent to the WP REST API.
1448     *
1449     * @return WP_REST_Response A response object..
1450     */
1451    public function delete_posts_by_status( $request ) {
1452        $from_status = $request->get_param( 'status' );
1453
1454        if ( ! in_array( $from_status, array( 'spam', 'trash' ), true ) ) {
1455            return new WP_REST_Response( array( 'error' => __( 'Bad request', 'jetpack-forms' ) ), 400 );
1456        }
1457
1458        // A `post_ids` that sanitizes to nothing must not fall through to "delete everything".
1459        if ( $request->has_param( 'post_ids' ) && empty( (array) $request->get_param( 'post_ids' ) ) ) {
1460            return new WP_REST_Response( array( 'error' => __( 'No valid responses to delete.', 'jetpack-forms' ) ), 400 );
1461        }
1462
1463        $status        = $from_status;
1464        $limit         = $request->get_param( 'limit' );
1465        $limit         = is_numeric( $limit ) ? (int) $limit : 0;
1466        $deadline      = $limit > 0 ? microtime( true ) + self::BULK_DELETE_TIME_BUDGET : 0;
1467        $batch_size    = 1000;
1468        $total_deleted = 0;
1469        $has_more      = true;
1470        $processed_ids = array();
1471
1472        $has_source_hooks = $this->maybe_attach_source_filter_hooks( $request );
1473
1474        while ( $has_more ) {
1475            $want        = $limit > 0 ? min( $batch_size, $limit - $total_deleted ) : $batch_size;
1476            $fetched_ids = $this->fetch_bulk_scope_batch( $request, $status, $want );
1477            // Skip IDs already handled, so a delete short-circuited by `pre_delete_post` can't loop forever.
1478            $post_ids = array_values( array_diff( $fetched_ids, $processed_ids ) );
1479
1480            if ( empty( $post_ids ) ) {
1481                break;
1482            }
1483
1484            foreach ( $post_ids as $post_id ) {
1485                $feedback_deleted = wp_delete_post( $post_id, true );
1486
1487                if ( ! $feedback_deleted ) {
1488                    if ( $has_source_hooks ) {
1489                        $this->remove_source_filter_hooks();
1490                    }
1491                    $error = $status === 'spam'
1492                        ? __( 'Failed to empty spam.', 'jetpack-forms' )
1493                        : __( 'Failed to empty trash.', 'jetpack-forms' );
1494                    return new WP_REST_Response(
1495                        array(
1496                            'error'   => $error,
1497                            'deleted' => $total_deleted,
1498                        ),
1499                        400
1500                    );
1501                }
1502
1503                $processed_ids[] = $post_id;
1504                ++$total_deleted;
1505
1506                if ( $deadline && microtime( true ) >= $deadline ) {
1507                    break 2;
1508                }
1509            }
1510
1511            if ( count( $fetched_ids ) < $want || ( $limit > 0 && $total_deleted >= $limit ) ) {
1512                $has_more = false;
1513            }
1514        }
1515
1516        $has_more = false;
1517        if ( $limit > 0 ) {
1518            $next     = array_diff( $this->fetch_bulk_scope_batch( $request, $status, 1 ), $processed_ids );
1519            $has_more = ! empty( $next );
1520        }
1521
1522        if ( $has_source_hooks ) {
1523            $this->remove_source_filter_hooks();
1524        }
1525
1526        return new WP_REST_Response(
1527            array(
1528                'deleted'  => $total_deleted,
1529                'has_more' => $has_more,
1530            ),
1531            200
1532        );
1533    }
1534
1535    /**
1536     * Performs the Akismet action to mark all feedback posts matching the given IDs as spam.
1537     *
1538     * @param  array $post_ids Array of post IDs.
1539     * @return WP_REST_Response
1540     */
1541    private function bulk_action_mark_as_spam( $post_ids ) {
1542        foreach ( $post_ids as $post_id ) {
1543            /** This action is documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
1544            do_action(
1545                'contact_form_akismet',
1546                'spam',
1547                get_post_meta( $post_id, '_feedback_akismet_values', true )
1548            );
1549        }
1550        return new WP_REST_Response( array(), 200 );
1551    }
1552
1553    /**
1554     * Performs the Akismet action to mark all feedback posts matching the given IDs as not spam.
1555     *
1556     * @param  array $post_ids Array of post IDs.
1557     * @return WP_REST_Response
1558     */
1559    private function bulk_action_mark_as_not_spam( $post_ids ) {
1560        foreach ( $post_ids as $post_id ) {
1561            /** This action is documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
1562            do_action(
1563                'contact_form_akismet',
1564                'ham',
1565                get_post_meta( $post_id, '_feedback_akismet_values', true )
1566            );
1567        }
1568        return new WP_REST_Response( array(), 200 );
1569    }
1570
1571    /**
1572     * Check whether a given request has proper authorization to view and edit feedback items.
1573     *
1574     * @param  WP_REST_Request $request Full details about the request.
1575     * @return WP_Error|boolean
1576     */
1577    public function get_items_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1578        if ( is_super_admin() ) {
1579            return true;
1580        }
1581
1582        if ( ! current_user_can( 'edit_pages' ) ) {
1583            return false;
1584        }
1585
1586        if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1587            return new WP_Error(
1588                'rest_cannot_view',
1589                esc_html__( 'Sorry, you cannot view this resource.', 'jetpack-forms' ),
1590                array( 'status' => 401 )
1591            );
1592        }
1593
1594        return true;
1595    }
1596
1597    /**
1598     * Check whether a given request has proper authorization to delete feedback items.
1599     *
1600     * @param  WP_REST_Request $request Full details about the request.
1601     * @return WP_Error|boolean
1602     */
1603    public function delete_items_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1604        if ( is_super_admin() ) {
1605            return true;
1606        }
1607
1608        if ( ! current_user_can( 'delete_others_pages' ) ) {
1609            return false;
1610        }
1611
1612        if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1613            return new WP_Error(
1614                'rest_user_cannot_delete_post',
1615                esc_html__( 'Sorry, you cannot delete this resource.', 'jetpack-forms' ),
1616                array( 'status' => 401 )
1617            );
1618        }
1619
1620        return true;
1621    }
1622
1623    /**
1624     * Check whether a given request has proper authorization to view feedback item.
1625     *
1626     * @param  WP_REST_Request $request Full details about the request.
1627     * @return WP_Error|boolean
1628     */
1629    public function get_item_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1630        if ( ! current_user_can( 'edit_pages' ) ) {
1631            return false;
1632        }
1633        if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1634            return new WP_Error(
1635                'rest_cannot_view',
1636                esc_html__( 'Sorry, you cannot view this resource.', 'jetpack-forms' ),
1637                array( 'status' => 401 )
1638            );
1639        }
1640
1641        return true;
1642    }
1643
1644    /**
1645     * Get static metadata for an integration (without status checks).
1646     *
1647     * @param string $slug Integration slug.
1648     * @param array  $config Integration configuration.
1649     * @return array Integration metadata.
1650     */
1651    private function get_integration_metadata_fields( $slug, $config ) {
1652        $type                    = $config['type'] ?? null;
1653        $marketing_redirect_slug = $config['marketing_redirect_slug'] ?? null;
1654        $icon_url                = $config['icon_url'] ?? null;
1655
1656        return array(
1657            'id'               => $slug,
1658            'slug'             => $slug,
1659            'type'             => $type,
1660            'title'            => isset( $config['title'] ) ? sanitize_text_field( $config['title'] ) : '',
1661            'subtitle'         => isset( $config['subtitle'] ) ? sanitize_text_field( $config['subtitle'] ) : '',
1662            'marketingUrl'     => $marketing_redirect_slug ? Redirect::get_url( $marketing_redirect_slug ) : null,
1663            'enabledByDefault' => isset( $config['enabled_by_default'] ) ? (bool) $config['enabled_by_default'] : false,
1664            'iconUrl'          => $icon_url ? esc_url_raw( $icon_url ) : null,
1665            'activeTooltip'    => isset( $config['active_tooltip'] ) ? sanitize_text_field( $config['active_tooltip'] ) : '',
1666        );
1667    }
1668
1669    /**
1670     * Core logic for a single integration
1671     *
1672     * @param string $slug Integration slug.
1673     * @return array Integration status data.
1674     */
1675    private function get_integration( $slug ) {
1676        $config = $this->get_supported_integrations()[ $slug ];
1677        $type   = $config['type'] ?? null;
1678
1679        // Start with metadata fields
1680        $base = $this->get_integration_metadata_fields( $slug, $config );
1681
1682        // Add status fields that require checks
1683        $base['pluginFile']      = ( $type === 'plugin' && ! empty( $config['file'] ) ) ? str_replace( '.php', '', $config['file'] ) : null;
1684        $base['isInstalled']     = false;
1685        $base['isActive']        = false;
1686        $base['needsConnection'] = ( $type === 'service' );
1687        $base['isConnected']     = false;
1688        $base['version']         = null;
1689        $base['settingsUrl']     = null;
1690        $base['details']         = array();
1691
1692        // Override base shape based on integration type.
1693        $status = $type === 'plugin'
1694            ? $this->get_plugin_status( $slug, $base )
1695            : $this->get_service_status( $slug, $base );
1696
1697        return $status;
1698    }
1699
1700    /**
1701     * REST callback for /integrations/{slug}
1702     *
1703     * @param WP_REST_Request $request Request object.
1704     * @return WP_REST_Response|WP_Error Response object or error.
1705     */
1706    public function get_single_integration_status( $request ) {
1707        $slug         = $request->get_param( 'slug' );
1708        $integrations = $this->get_supported_integrations();
1709        if ( ! isset( $integrations[ $slug ] ) ) {
1710            return new \WP_Error( 'rest_integration_not_found', __( 'Integration not found.', 'jetpack-forms' ), array( 'status' => 404 ) );
1711        }
1712        return rest_ensure_response( $this->get_integration( $slug ) );
1713    }
1714
1715    /**
1716     * REST callback for /integrations
1717     *
1718     * @param WP_REST_Request $request Request object.
1719     * @return WP_REST_Response Response object.
1720     */
1721    public function get_all_integrations_status( $request ) {
1722        $version      = absint( $request->get_param( 'version' ) );
1723        $integrations = array();
1724
1725        foreach ( $this->get_supported_integrations() as $slug => $config ) {
1726            $status = $this->get_integration( $slug );
1727            if ( 1 === $version ) {
1728                $integrations[ $slug ] = $status;
1729            } else {
1730                $integrations[] = $status;
1731            }
1732        }
1733
1734        return rest_ensure_response( $integrations );
1735    }
1736
1737    /**
1738     * REST callback for /integrations-metadata
1739     *
1740     * Returns only static metadata (name, description, type, etc.) without making
1741     * expensive calls to check connection status or plugin installation status.
1742     * This endpoint is designed to be fast and suitable for preloading.
1743     *
1744     * Uses the same field generation logic as get_integration() to ensure consistency.
1745     *
1746     * @param WP_REST_Request $request Request object.
1747     * @return WP_REST_Response Response object.
1748     */
1749    public function get_integrations_metadata( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1750        $integrations = array();
1751
1752        foreach ( $this->get_supported_integrations() as $slug => $config ) {
1753            $integrations[] = $this->get_integration_metadata_fields( $slug, $config );
1754        }
1755
1756        return rest_ensure_response( $integrations );
1757    }
1758
1759    /**
1760     * Get status for internal/service integrations.
1761     *
1762     * @param string $slug   Service slug.
1763     * @param array  $status Base status shape to mutate and return.
1764     * @return array Service status data.
1765     */
1766    private function get_service_status( $slug, array $status ) {
1767        $config = $this->get_supported_integrations()[ $slug ];
1768
1769        // If a settings redirect slug/url is configured, convert to full URL
1770        if ( ! empty( $config['settings_url'] ) ) {
1771            $status['settingsUrl'] = esc_url( Redirect::get_url( $config['settings_url'] ) );
1772        }
1773
1774        // Each integration reports its own connection state.
1775        if ( ! empty( $config['status_callback'] ) && is_callable( $config['status_callback'] ) ) {
1776            $reported = call_user_func( $config['status_callback'], $status, $slug );
1777
1778            if ( is_array( $reported ) ) {
1779                $status = $reported;
1780            }
1781        }
1782
1783        return $status;
1784    }
1785
1786    /**
1787     * Get plugin status.
1788     *
1789     * @param string $plugin_slug Plugin slug.
1790     * @param array  $status      Base status shape to mutate and return.
1791     * @return array Plugin status data.
1792     */
1793    private function get_plugin_status( $plugin_slug, array $status ) {
1794        if ( ! function_exists( 'get_plugins' ) ) {
1795            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1796        }
1797
1798        $integrations  = $this->get_supported_integrations();
1799        $plugin_config = $integrations[ $plugin_slug ];
1800
1801        $installed_plugins = get_plugins();
1802        $is_installed      = isset( $installed_plugins[ $plugin_config['file'] ] );
1803        $is_active         = is_plugin_active( $plugin_config['file'] );
1804
1805        // Override base shape for all plugins.
1806        $status['pluginFile']  = str_replace( '.php', '', $plugin_config['file'] );
1807        $status['isInstalled'] = $is_installed;
1808        $status['isActive']    = $is_active;
1809        $status['version']     = $is_installed ? $installed_plugins[ $plugin_config['file'] ]['Version'] : null;
1810        $status['settingsUrl'] = ( $is_active && ! empty( $plugin_config['settings_url'] ) )
1811            ? admin_url( $plugin_config['settings_url'] )
1812            : null;
1813
1814        // Override base shape for specific plugins.
1815        switch ( $plugin_slug ) {
1816            case 'akismet':
1817                $status['isConnected']                       = class_exists( 'Jetpack' ) && \Jetpack::is_akismet_active();
1818                $status['details']['formSubmissionsSpamUrl'] = Forms_Dashboard::get_forms_admin_url( 'spam' );
1819                $status['needsConnection']                   = true;
1820                break;
1821            case 'zero-bs-crm':
1822                if ( $is_active ) {
1823                    $has_extension     = function_exists( 'zeroBSCRM_isExtensionInstalled' ) && zeroBSCRM_isExtensionInstalled( 'jetpackforms' ); // @phan-suppress-current-line PhanUndeclaredFunction -- We're checking the function exists first
1824                    $status['details'] = array(
1825                        'hasExtension'         => $has_extension,
1826                        'canActivateExtension' => current_user_can( 'manage_options' ),
1827                    );
1828                }
1829                break;
1830            case 'mailpoet':
1831                $status['needsConnection'] = true;
1832                // Determine if MailPoet setup is complete using the public API.
1833                if ( class_exists( \MailPoet\API\API::class ) ) { // @phan-suppress-current-line PhanUndeclaredClassReference
1834                    $mailpoet_api = \MailPoet\API\API::MP( 'v1' ); // @phan-suppress-current-line PhanUndeclaredClassMethod
1835                    if ( $mailpoet_api && method_exists( $mailpoet_api, 'isSetupComplete' ) ) {
1836                        $status['isConnected'] = (bool) $mailpoet_api->isSetupComplete();
1837                    }
1838                }
1839                // Add MailPoet lists to details
1840                $status['details']['lists'] = MailPoet_Integration::get_all_lists();
1841                break;
1842            case 'hostinger-reach':
1843                // Hostinger Reach is a plugin that requires additional setup/connection.
1844                $status['needsConnection'] = true;
1845                $status['isConnected']     = false;
1846                // Determine if Hostinger Reach is connected using its public handler.
1847                if ( $is_active
1848                    // @phan-suppress-next-line PhanUndeclaredClassReference
1849                    && class_exists( \Hostinger\Reach\Api\Handlers\ReachApiHandler::class )
1850                    // @phan-suppress-next-line PhanUndeclaredClassReference
1851                    && class_exists( \Hostinger\Reach\Functions::class )
1852                    // @phan-suppress-next-line PhanUndeclaredClassReference
1853                    && class_exists( \Hostinger\Reach\Api\ApiKeyManager::class )
1854                ) {
1855                    $reach_handler = new \Hostinger\Reach\Api\Handlers\ReachApiHandler( // @phan-suppress-current-line PhanUndeclaredClassMethod
1856                        new \Hostinger\Reach\Functions(), // @phan-suppress-current-line PhanUndeclaredClassMethod
1857                        new \Hostinger\Reach\Api\ApiKeyManager() // @phan-suppress-current-line PhanUndeclaredClassMethod
1858                    );
1859                    if ( method_exists( $reach_handler, 'is_connected' ) ) {
1860                        // @phan-suppress-next-line PhanUndeclaredClassMethod
1861                        $status['isConnected'] = (bool) $reach_handler->is_connected();
1862                    }
1863                }
1864                break;
1865        }
1866
1867        return $status;
1868    }
1869
1870    /**
1871     * REST callback for DELETE /integrations/{slug}
1872     *
1873     * @param WP_REST_Request $request Request object.
1874     * @return WP_REST_Response|WP_Error Response object or error.
1875     */
1876    public function disable_integration( $request ) {
1877        $slug         = $request->get_param( 'slug' );
1878        $integrations = $this->get_supported_integrations();
1879        if ( ! isset( $integrations[ $slug ] ) ) {
1880            return new \WP_Error( 'rest_integration_not_found', __( 'Integration not found.', 'jetpack-forms' ), array( 'status' => 404 ) );
1881        }
1882        if ( $slug !== 'google-drive' ) {
1883            return new \WP_Error( 'rest_integration_invalid', __( 'This integration cannot be disabled.', 'jetpack-forms' ), array( 'status' => 404 ) );
1884        }
1885        $is_deleted = External_Connections::delete_connection( $slug );
1886        return rest_ensure_response( array( 'deleted' => $is_deleted ) );
1887    }
1888
1889    /**
1890     * Updates the read/unread status of a feedback item.
1891     *
1892     * @param WP_REST_Request $request Request object.
1893     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
1894     */
1895    public function update_read_status( $request ) {
1896        $post_id   = $request->get_param( 'id' );
1897        $is_unread = $request->get_param( 'is_unread' );
1898
1899        $feedback_response = Feedback::get( $post_id );
1900        if ( ! $feedback_response ) {
1901            return new WP_Error(
1902                'rest_post_invalid_id',
1903                __( 'Invalid feedback ID.', 'jetpack-forms' ),
1904                array( 'status' => 404 )
1905            );
1906        }
1907
1908        $success = $is_unread ? $feedback_response->mark_as_unread() : $feedback_response->mark_as_read();
1909
1910        Contact_Form_Plugin::recalculate_unread_count();
1911        if ( ! $success ) {
1912            return new WP_Error(
1913                'rest_cannot_update',
1914                __( 'Failed to update feedback read status.', 'jetpack-forms' ),
1915                array( 'status' => 500 )
1916            );
1917        }
1918
1919        return rest_ensure_response(
1920            array(
1921                'id'        => $post_id,
1922                'is_unread' => $feedback_response->is_unread(),
1923                'count'     => Contact_Form_Plugin::get_unread_count(),
1924            )
1925        );
1926    }
1927
1928    /**
1929     * Dismiss the classic forms notice by updating the option to 'dismissed'.
1930     *
1931     * @return WP_REST_Response
1932     */
1933    public function dismiss_classic_forms_notice() {
1934        update_option( Forms_Dashboard::CLASSIC_FORMS_OPTION, Forms_Dashboard::CLASSIC_FORMS_STATE_DISMISSED, false );
1935
1936        return rest_ensure_response( array( 'success' => true ) );
1937    }
1938
1939    /**
1940     * Return consolidated Forms config payload.
1941     *
1942     * @param WP_REST_Request $request Request.
1943     * @return WP_REST_Response
1944     */
1945    public function get_forms_config( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1946        $config = array(
1947            // Feature flags.
1948            'isCentralFormManagementEnabled' => Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ),
1949            // From jpFormsBlocks in class-contact-form-block.php.
1950            'formsResponsesUrl'              => Forms_Dashboard::get_forms_admin_url(),
1951            'isMailPoetEnabled'              => Jetpack_Forms::is_mailpoet_enabled(),
1952            'isHostingerReachEnabled'        => Jetpack_Forms::is_hostinger_reach_enabled(),
1953            // From config in class-dashboard.php.
1954            'blogId'                         => get_current_blog_id(),
1955            'gdriveConnectSupportURL'        => esc_url( Redirect::get_url( 'jetpack-support-contact-form-export' ) ),
1956            'pluginAssetsURL'                => Jetpack_Forms::assets_url(),
1957            'fileIconsUrl'                   => Jetpack_Forms::plugin_url() . 'contact-form/images/file-icons/',
1958            'siteURL'                        => ( new Status() )->get_site_suffix(),
1959            'hasFeedback'                    => ( new Forms_Dashboard() )->has_feedback(),
1960            'hasClassicForms'                => ( new Forms_Dashboard() )->get_classic_forms_state() === Forms_Dashboard::CLASSIC_FORMS_STATE_CLASSIC,
1961            'isNotesEnabled'                 => Forms_Dashboard::is_notes_enabled(),
1962            'isIntegrationsEnabled'          => Jetpack_Forms::is_integrations_enabled(),
1963            'isWebhooksEnabled'              => Jetpack_Forms::is_webhooks_enabled(),
1964            'showDashboardIntegrations'      => Jetpack_Forms::show_dashboard_integrations(),
1965            'showBlockIntegrations'          => Jetpack_Forms::show_block_integrations(),
1966            'showIntegrationIcons'           => Jetpack_Forms::show_integration_icons(),
1967            'dashboardURL'                   => Forms_Dashboard::get_forms_admin_url(),
1968            // New data.
1969            'canInstallPlugins'              => current_user_can( 'install_plugins' ),
1970            'canActivatePlugins'             => current_user_can( 'activate_plugins' ),
1971            'exportNonce'                    => wp_create_nonce( 'feedback_export' ),
1972            'newFormNonce'                   => wp_create_nonce( 'create_new_form' ),
1973            'emptyTrashDays'                 => defined( 'EMPTY_TRASH_DAYS' ) ? EMPTY_TRASH_DAYS : 0,
1974            // Admin URLs for external admin contexts.
1975            'adminUrl'                       => admin_url(),
1976            'ajaxUrl'                        => admin_url( 'admin-ajax.php' ),
1977        );
1978
1979        return rest_ensure_response( $config );
1980    }
1981}