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