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