Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.56% covered (warning)
85.56%
954 / 1115
27.27% covered (danger)
27.27%
9 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
Contact_Form_Endpoint
85.62% covered (warning)
85.62%
953 / 1113
27.27% covered (danger)
27.27%
9 / 33
257.87
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%
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
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 filter_by_source_id
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 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']['form_fill_duration'] = array(
663            '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' ),
664            'type'        => array( 'integer', 'null' ),
665            'context'     => array( 'view', 'edit', 'embed' ),
666            // No sanitize_callback: the field is readonly and sanitized on storage, and
667            // `absint` would coerce a legitimate null into 0.
668            'readonly'    => true,
669        );
670
671        $schema['properties']['browser'] = array(
672            'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ),
673            'type'        => 'string',
674            'context'     => array( 'view', 'edit', 'embed' ),
675            'arg_options' => array(
676                'sanitize_callback' => 'sanitize_text_field',
677            ),
678            'readonly'    => true,
679        );
680
681        $schema['properties']['logged_in_user'] = array(
682            'description' => __( 'The logged-in user who submitted the form, if any.', 'jetpack-forms' ),
683            'type'        => array( 'object', 'null' ),
684            'context'     => array( 'view', 'edit', 'embed' ),
685            'properties'  => array(
686                'display_name' => array(
687                    'type'        => 'string',
688                    'description' => __( 'The display name of the logged-in user.', 'jetpack-forms' ),
689                    'arg_options' => array(
690                        'sanitize_callback' => 'sanitize_text_field',
691                    ),
692                ),
693                'username'     => array(
694                    'type'        => 'string',
695                    'description' => __( 'The username of the logged-in user.', 'jetpack-forms' ),
696                    'arg_options' => array(
697                        'sanitize_callback' => 'sanitize_text_field',
698                    ),
699                ),
700                'id'           => array(
701                    'type'        => 'integer',
702                    'description' => __( 'The ID of the logged-in user.', 'jetpack-forms' ),
703                    'arg_options' => array(
704                        'sanitize_callback' => 'absint',
705                    ),
706                ),
707            ),
708            'readonly'    => true,
709        );
710
711        $schema['properties']['entry_title'] = array(
712            'description' => __( 'The title of the page or post where the form was submitted.', 'jetpack-forms' ),
713            'type'        => 'string',
714            'context'     => array( 'view', 'edit', 'embed' ),
715            'arg_options' => array(
716                'sanitize_callback' => 'sanitize_text_field',
717            ),
718            'readonly'    => true,
719        );
720
721        $schema['properties']['entry_permalink'] = array(
722            'description' => __( 'The URL of the page or post where the form was submitted.', 'jetpack-forms' ),
723            'type'        => 'string',
724            'context'     => array( 'view', 'edit', 'embed' ),
725            'arg_options' => array(
726                'sanitize_callback' => 'sanitize_text_field',
727            ),
728            'readonly'    => true,
729        );
730
731        $schema['properties']['form_id'] = array(
732            'description' => __( 'The ID of the jetpack_form post the response is tied to, or 0 for classic (embedded) forms.', 'jetpack-forms' ),
733            'type'        => 'integer',
734            'context'     => array( 'view', 'edit', 'embed' ),
735            'readonly'    => true,
736        );
737
738        $schema['properties']['edit_form_url'] = array(
739            'description' => __( 'The URL to edit the form.', 'jetpack-forms' ),
740            'type'        => 'string',
741            'context'     => array( 'view', 'edit', 'embed' ),
742            'arg_options' => array(
743                'sanitize_callback' => 'sanitize_text_field',
744            ),
745            'readonly'    => true,
746        );
747
748        $schema['properties']['subject'] = array(
749            'description' => __( 'The subject line of the form submission.', 'jetpack-forms' ),
750            'type'        => 'string',
751            'context'     => array( 'view', 'edit', 'embed' ),
752            'arg_options' => array(
753                'sanitize_callback' => 'sanitize_text_field',
754            ),
755            'readonly'    => true,
756        );
757
758        $schema['properties']['fields'] = array(
759            'description' => __( 'The custom form fields and their submitted values.', 'jetpack-forms' ),
760            'type'        => 'object',
761            'context'     => array( 'view', 'edit', 'embed' ),
762            'arg_options' => array(
763                'sanitize_callback' => 'sanitize_text_field',
764            ),
765            'properties'  => array(
766                'files' => array(
767                    'type'       => 'object',
768                    'properties' => array(
769                        'field_id' => array(
770                            'type'        => 'string',
771                            'arg_options' => array(
772                                'sanitize_callback' => 'sanitize_text_field',
773                            ),
774                        ),
775                        'files'    => array(
776                            'type'  => 'array',
777                            'items' => array(
778                                'type'       => 'object',
779                                'properties' => array(
780                                    'file_id'        => array(
781                                        'type'        => 'integer',
782                                        'arg_options' => array(
783                                            'sanitize_callback' => 'sanitize_text_field',
784                                        ),
785                                    ),
786                                    'name'           => array(
787                                        'type'        => 'string',
788                                        'arg_options' => array(
789                                            'sanitize_callback' => 'sanitize_text_field',
790                                        ),
791                                    ),
792                                    'size'           => array(
793                                        'type'        => 'string',
794                                        'arg_options' => array(
795                                            'sanitize_callback' => 'sanitize_text_field',
796                                        ),
797                                    ),
798                                    'url'            => array(
799                                        'type'        => 'string',
800                                        'arg_options' => array(
801                                            'sanitize_callback' => 'esc_url_raw',
802                                        ),
803                                    ),
804                                    'is_previewable' => array(
805                                        'type'        => 'boolean',
806                                        'arg_options' => array(
807                                            'sanitize_callback' => 'rest_sanitize_boolean',
808                                        ),
809                                    ),
810                                ),
811                            ),
812                        ),
813                    ),
814                ),
815            ),
816            'readonly'    => true,
817        );
818
819        $schema['properties']['has_file'] = array(
820            'description' => __( 'Does the form response contain a file.', 'jetpack-forms' ),
821            'type'        => 'boolean',
822            'context'     => array( 'view', 'edit', 'embed' ),
823            'arg_options' => array(
824                'sanitize_callback' => 'booleanval',
825            ),
826            'readonly'    => true,
827        );
828
829        $schema['properties']['is_unread'] = array(
830            'description' => __( 'Whether the form response is unread.', 'jetpack-forms' ),
831            'type'        => 'boolean',
832            'context'     => array( 'view', 'edit', 'embed' ),
833            'arg_options' => array(
834                'sanitize_callback' => 'rest_sanitize_boolean',
835            ),
836            'readonly'    => true,
837        );
838
839        $schema['properties']['is_test'] = array(
840            'description' => __( 'Whether the form response was submitted from a form preview (test response).', 'jetpack-forms' ),
841            'type'        => 'boolean',
842            'context'     => array( 'view', 'edit', 'embed' ),
843            'arg_options' => array(
844                'sanitize_callback' => 'rest_sanitize_boolean',
845            ),
846            'readonly'    => true,
847        );
848
849        $schema['properties']['preview_url'] = array(
850            'description' => __( 'URL to the form preview that produced this response, when the response is a test submission.', 'jetpack-forms' ),
851            'type'        => array( 'string', 'null' ),
852            'context'     => array( 'view', 'edit', 'embed' ),
853            'arg_options' => array(
854                'sanitize_callback' => 'esc_url_raw',
855            ),
856            'readonly'    => true,
857        );
858
859        $this->schema = $schema;
860
861        return $this->add_additional_fields_schema( $this->schema );
862    }
863
864    /**
865     * Updates the item.
866     * Overrides the parent method to resend the email when the item is updated from spam to publish.
867     *
868     * @param WP_REST_Request $request Request object.
869     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
870     */
871    public function update_item( $request ) {
872        $valid_check = parent::get_post( $request['id'] );
873        if ( is_wp_error( $valid_check ) ) {
874            return $valid_check;
875        }
876
877        $post_id         = $request['id'];
878        $previous_status = get_post_status( $post_id );
879        $updated_item    = parent::update_item( $request );
880
881        if ( ! is_wp_error( $updated_item ) && ! empty( $updated_item->data && ! empty( $updated_item->data['status'] ) ) ) {
882            if ( $previous_status === 'spam' && $updated_item->data['status'] === 'publish' ) {
883                // updated item is going from spam to inbox
884                $akismet_values = get_post_meta( $post_id, '_feedback_akismet_values', true );
885                /** This action is documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
886                do_action( 'contact_form_akismet', 'ham', $akismet_values );
887                $this->resend_email( $post_id );
888            }
889        }
890        return $updated_item;
891    }
892
893    /**
894     * Resends the email for a given post ID.
895     *
896     * @param int $post_id The ID of the post to resend the email for.
897     */
898    public function resend_email( $post_id ) {
899        $comment_author_email = false;
900        $reply_to_addr        = false;
901        $message              = '';
902        $to                   = false;
903        $headers              = false;
904        $blog_url             = wp_parse_url( site_url() );
905
906        // resend the original email
907        $email = get_post_meta( $post_id, '_feedback_email', true );
908
909        $response = Feedback::get( $post_id );
910        if ( ! $response ) {
911            return;
912        }
913
914        if ( ! empty( $response->get_author_email() ) ) {
915            $comment_author_email = $response->get_author_email();
916        }
917
918        if ( isset( $email['to'] ) ) {
919            $to = $email['to'];
920        }
921
922        if ( isset( $email['message'] ) ) {
923            $message = $email['message'];
924        }
925
926        if ( isset( $email['headers'] ) ) {
927            $headers = $email['headers'];
928        } else {
929            $headers = 'From: "' . $response->get_author() . '" <wordpress@' . $blog_url['host'] . ">\r\n";
930
931            if ( ! empty( $comment_author_email ) ) {
932                $reply_to_addr = $comment_author_email;
933            } elseif ( is_array( $to ) ) {
934                $reply_to_addr = $to[0];
935            }
936
937            if ( $reply_to_addr ) {
938                $headers .= 'Reply-To: "' . $response->get_author() . '" <' . $reply_to_addr . ">\r\n";
939            }
940
941            $headers .= 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"';
942        }
943        Contact_Form::wp_mail( $to, $response->get_subject(), $message, $headers );
944    }
945
946    /**
947     * Prepares the item for the REST response.
948     *
949     * @param object          $item    WP Cron event.
950     * @param WP_REST_Request $request Request object.
951     * @return WP_REST_Response Response object on success.
952     */
953    public function prepare_item_for_response( $item, $request ) {
954        $response = parent::prepare_item_for_response( $item, $request );
955        $data     = $response->get_data();
956        $fields   = $this->get_fields_for_response( $request );
957
958        $feedback_response = Feedback::get( $item->ID );
959        if ( ! $feedback_response ) {
960            return rest_ensure_response( $data );
961        }
962
963        // Lazily backfill source meta for old feedback that doesn't have it yet.
964        Feedback::maybe_backfill_source_meta( $item->ID, $feedback_response );
965
966        $data['date'] = get_the_date( 'c', $data['id'] );
967        if ( rest_is_field_included( 'uid', $fields ) ) {
968            $data['uid'] = $feedback_response->get_feedback_id();
969        }
970
971        if ( rest_is_field_included( 'author_name', $fields ) ) {
972            $data['author_name'] = $feedback_response->get_author_name();
973        }
974
975        if ( rest_is_field_included( 'author_display_name', $fields ) ) {
976            $data['author_display_name'] = $feedback_response->get_author();
977        }
978
979        if ( rest_is_field_included( 'author_email', $fields ) ) {
980            $data['author_email'] = $feedback_response->get_author_email();
981        }
982
983        if ( rest_is_field_included( 'author_url', $fields ) ) {
984            $data['author_url'] = $feedback_response->get_author_url();
985        }
986
987        if ( rest_is_field_included( 'author_avatar', $fields ) ) {
988            $data['author_avatar'] = $feedback_response->get_author_avatar();
989        }
990
991        if ( rest_is_field_included( 'email_marketing_consent', $fields ) ) {
992            $data['email_marketing_consent'] = $feedback_response->has_consent() ? '1' : '';
993        }
994
995        if ( rest_is_field_included( 'ip', $fields ) ) {
996            $data['ip'] = $feedback_response->get_ip_address();
997        }
998
999        if ( rest_is_field_included( 'country_code', $fields ) ) {
1000            $data['country_code'] = $feedback_response->get_country_code();
1001        }
1002
1003        if ( rest_is_field_included( 'form_fill_duration', $fields ) ) {
1004            $data['form_fill_duration'] = $feedback_response->get_form_fill_duration();
1005        }
1006
1007        if ( rest_is_field_included( 'browser', $fields ) ) {
1008            $data['browser'] = $feedback_response->get_browser();
1009        }
1010
1011        if ( rest_is_field_included( 'logged_in_user', $fields ) ) {
1012            $data['logged_in_user'] = $feedback_response->get_logged_in_user();
1013        }
1014
1015        if ( rest_is_field_included( 'entry_title', $fields ) ) {
1016            $data['entry_title'] = $feedback_response->get_entry_title();
1017        }
1018
1019        if ( rest_is_field_included( 'entry_permalink', $fields ) ) {
1020            $data['entry_permalink'] = $feedback_response->get_entry_permalink();
1021        }
1022
1023        if ( rest_is_field_included( 'form_id', $fields ) ) {
1024            $data['form_id'] = (int) $feedback_response->get_form_id();
1025        }
1026
1027        if ( rest_is_field_included( 'edit_form_url', $fields ) ) {
1028            $data['edit_form_url'] = $feedback_response->get_edit_form_url();
1029        }
1030
1031        if ( rest_is_field_included( 'subject', $fields ) ) {
1032            $data['subject'] = $feedback_response->get_subject();
1033        }
1034
1035        if ( rest_is_field_included( 'fields', $fields ) ) {
1036            $fields_format  = $request->get_param( 'fields_format' ) ?? 'label-value';
1037            $data['fields'] = $feedback_response->get_compiled_fields( 'api', $fields_format );
1038        }
1039
1040        if ( rest_is_field_included( 'has_file', $fields ) ) {
1041            $data['has_file'] = $feedback_response->has_file();
1042        }
1043
1044        if ( rest_is_field_included( 'is_unread', $fields ) ) {
1045            $data['is_unread'] = $feedback_response->is_unread();
1046        }
1047
1048        if ( rest_is_field_included( 'is_test', $fields ) ) {
1049            $data['is_test'] = $feedback_response->is_test();
1050        }
1051
1052        if ( rest_is_field_included( 'preview_url', $fields ) ) {
1053            $preview_url = null;
1054            if ( $feedback_response->is_test() ) {
1055                $form_id = $feedback_response->get_form_id();
1056                if ( $form_id ) {
1057                    $preview_url = Form_Preview::generate_preview_url( (int) $form_id );
1058                }
1059            }
1060            $data['preview_url'] = $preview_url;
1061        }
1062
1063        $response->set_data( $data );
1064
1065        return rest_ensure_response( $response );
1066    }
1067
1068    /**
1069     * Retrieves a collection of feedback items.
1070     * Overrides parent to support invalid_ids with OR logic.
1071     *
1072     * @param WP_REST_Request $request Full details about the request.
1073     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
1074     */
1075    public function get_items( $request ) {
1076        $invalid_ids = $request->get_param( 'invalid_ids' );
1077
1078        // If we have invalid_ids, we need to modify the query with a WHERE clause
1079        if ( ! empty( $invalid_ids ) ) {
1080            add_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10, 2 );
1081            // Store invalid_ids temporarily so the filter can access them
1082            $this->temp_invalid_ids = $invalid_ids;
1083        }
1084
1085        $response = parent::get_items( $request );
1086
1087        // Clean up
1088        if ( ! empty( $invalid_ids ) ) {
1089            remove_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10 );
1090            unset( $this->temp_invalid_ids );
1091        }
1092        if ( ! empty( $this->temp_source_filter_id ) ) {
1093            remove_filter( 'posts_join', array( $this, 'join_source_meta' ), 10 );
1094            remove_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10 );
1095            $this->temp_source_filter_id  = null;
1096            $this->temp_source_filter_sql = null;
1097        }
1098
1099        return $response;
1100    }
1101
1102    /**
1103     * Modify the WHERE clause to include invalid_ids with OR logic.
1104     *
1105     * @param string   $where The WHERE clause.
1106     * @param WP_Query $query The WP_Query instance.
1107     * @return string Modified WHERE clause.
1108     */
1109    public function modify_query_for_invalid_ids( $where, $query ) {
1110        global $wpdb;
1111
1112        // Only modify our feedback queries
1113        if ( ! isset( $this->temp_invalid_ids ) || empty( $this->temp_invalid_ids ) ) {
1114            return $where;
1115        }
1116
1117        // Only modify if this is a feedback query
1118        $post_type = $query->get( 'post_type' );
1119        if ( $post_type !== 'feedback' ) {
1120            return $where;
1121        }
1122
1123        $invalid_ids_sql = implode( ',', array_map( 'absint', $this->temp_invalid_ids ) );
1124
1125        // Wrap the existing WHERE in parentheses before appending the OR branch, and re-assert
1126        // post_type='feedback' on the OR side. SQL AND binds tighter than OR; without these
1127        // guards the appended " OR ID IN (...)" would collapse the existing post_type filter
1128        // and let any post ID (regardless of type or status) be returned by the endpoint.
1129        $where = "({$where}) OR ({$wpdb->posts}.ID IN ({$invalid_ids_sql}) AND {$wpdb->posts}.post_type = 'feedback')";
1130
1131        return $where;
1132    }
1133
1134    /**
1135     * Filters the query arguments for the feedback collection.
1136     *
1137     * @param array           $args    Key value array of query var to query value.
1138     * @param WP_REST_Request $request The request used.
1139     * @return array Modified query arguments.
1140     */
1141    protected function prepare_items_query( $args = array(), $request = null ) {
1142        $args = parent::prepare_items_query( $args, $request );
1143
1144        if ( isset( $request['is_unread'] ) ) {
1145            $args['comment_status'] = $request['is_unread'] ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
1146        }
1147
1148        // Filter by source post ID using meta (with fallback to post_parent for old data).
1149        $source = $request->get_param( 'source' );
1150        if ( ! empty( $source ) ) {
1151            $this->temp_source_filter_id  = absint( $source );
1152            $this->temp_source_filter_sql = Feedback::get_source_filter_sql( $this->temp_source_filter_id );
1153            add_filter( 'posts_join', array( $this, 'join_source_meta' ), 10, 2 );
1154            add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 );
1155        }
1156
1157        // Filter by test/non-test responses via the _feedback_is_test meta.
1158        $is_test = $request->get_param( 'is_test' );
1159        if ( null !== $is_test ) {
1160            $meta_query = isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : array();
1161            if ( $is_test ) {
1162                $meta_query[] = array(
1163                    'key'     => Feedback::IS_TEST_META_KEY,
1164                    'value'   => '1',
1165                    'compare' => '=',
1166                );
1167            } else {
1168                $meta_query[] = array(
1169                    'key'     => Feedback::IS_TEST_META_KEY,
1170                    'compare' => 'NOT EXISTS',
1171                );
1172            }
1173            $args['meta_query'] = $meta_query;
1174        }
1175
1176        return $args;
1177    }
1178
1179    /**
1180     * Joins the postmeta table for source filtering.
1181     *
1182     * @param string   $join  The JOIN clause.
1183     * @param WP_Query $query The WP_Query instance.
1184     * @return string Modified JOIN clause.
1185     */
1186    public function join_source_meta( $join, $query ) {
1187        if ( empty( $this->temp_source_filter_sql ) || Feedback::POST_TYPE !== $query->get( 'post_type' ) ) {
1188            return $join;
1189        }
1190        return $join . $this->temp_source_filter_sql['join'];
1191    }
1192
1193    /**
1194     * Filters feedback by source post ID, using meta with fallback to post_parent for old data.
1195     *
1196     * @param string   $where The WHERE clause.
1197     * @param WP_Query $query The WP_Query instance.
1198     * @return string Modified WHERE clause.
1199     */
1200    public function filter_by_source_id( $where, $query ) {
1201        if ( empty( $this->temp_source_filter_sql ) || Feedback::POST_TYPE !== $query->get( 'post_type' ) ) {
1202            return $where;
1203        }
1204        return $where . ' AND ' . $this->temp_source_filter_sql['where'];
1205    }
1206
1207    /**
1208     * Retrieves the query params for the feedback collection.
1209     *
1210     * @return array Collection parameters.
1211     */
1212    public function get_collection_params() {
1213        $query_params = parent::get_collection_params();
1214
1215        // Add parent related query parameters since the `feedback` post type is not hierarchical, but
1216        // it uses the `parent` field to store the ID of the post/page where the feedback was created.
1217        $query_params['parent']         = array(
1218            'description' => __( 'Limit result set to items with particular parent IDs.', 'jetpack-forms' ),
1219            'type'        => 'array',
1220            'items'       => array(
1221                'type' => 'integer',
1222            ),
1223            'default'     => array(),
1224        );
1225        $query_params['parent_exclude'] = array(
1226            'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'jetpack-forms' ),
1227            'type'        => 'array',
1228            'items'       => array(
1229                'type' => 'integer',
1230            ),
1231            'default'     => array(),
1232        );
1233        $query_params['source']         = array(
1234            'description'       => __( 'Limit result set to feedback submitted from a particular source post ID.', 'jetpack-forms' ),
1235            'type'              => 'integer',
1236            'sanitize_callback' => 'absint',
1237            'validate_callback' => 'rest_validate_request_arg',
1238        );
1239        $query_params['is_unread']      = array(
1240            'description'       => __( 'Limit result set to read or unread feedback items.', 'jetpack-forms' ),
1241            'type'              => 'boolean',
1242            'sanitize_callback' => 'rest_sanitize_boolean',
1243            'validate_callback' => 'rest_validate_request_arg',
1244        );
1245        $query_params['is_test']        = array(
1246            'description'       => __( 'Limit result set to test responses (from form preview) or exclude them.', 'jetpack-forms' ),
1247            'type'              => 'boolean',
1248            'sanitize_callback' => 'rest_sanitize_boolean',
1249            'validate_callback' => 'rest_validate_request_arg',
1250        );
1251        $query_params['invalid_ids']    = array(
1252            'description'       => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ),
1253            'type'              => 'array',
1254            'items'             => array(
1255                'type' => 'integer',
1256            ),
1257            'default'           => array(),
1258            'sanitize_callback' => function ( $param ) {
1259                return array_map( 'absint', (array) $param );
1260            },
1261            'validate_callback' => 'rest_validate_request_arg',
1262        );
1263        $query_params['fields_format']  = array(
1264            'description'       => __( 'Format for the fields data in the response.', 'jetpack-forms' ),
1265            'type'              => 'string',
1266            'enum'              => array( 'label-value', 'collection' ),
1267            'default'           => 'label-value',
1268            'sanitize_callback' => 'sanitize_text_field',
1269            'validate_callback' => 'rest_validate_request_arg',
1270        );
1271        return $query_params;
1272    }
1273
1274    /**
1275     * Handles bulk actions for Jetpack Forms responses.
1276     *
1277     * @param WP_REST_Request $request The request sent to the WP REST API.
1278     *
1279     * @return WP_REST_Response A response object..
1280     */
1281    public function bulk_actions( $request ) {
1282        $action   = $request->get_param( 'action' );
1283        $post_ids = $request->get_param( 'post_ids' );
1284
1285        if ( $action && ! is_array( $post_ids ) ) {
1286            return new WP_REST_Response( array( 'error' => __( 'Bad request', 'jetpack-forms' ) ), 400 );
1287        }
1288
1289        switch ( $action ) {
1290            case 'mark_as_spam':
1291                return $this->bulk_action_mark_as_spam( $post_ids );
1292
1293            case 'mark_as_not_spam':
1294                return $this->bulk_action_mark_as_not_spam( $post_ids );
1295
1296            default:
1297                return new WP_REST_Response( array( 'error' => __( 'Bad request', 'jetpack-forms' ) ), 400 );
1298        }
1299    }
1300
1301    /**
1302     * Handles emptying Jetpack Forms responses based on status.
1303     *
1304     * By default, it empties the trash, meaning it will delete all feedbacks in the trash (status = trash).
1305     * Passing a status will delete all feedbacks in the status.
1306     * The operation is non reversible and thus restricted to statuses spam and trash,
1307     * enforced by endpoint query args enum[spam, trash] but also double checked by the endpoint.
1308     *
1309     * @param WP_REST_Request $request The request sent to the WP REST API.
1310     *
1311     * @return WP_REST_Response A response object..
1312     */
1313    public function delete_posts_by_status( $request ) {
1314        $from_status = $request->get_param( 'status' );
1315
1316        if ( ! in_array( $from_status, array( 'spam', 'trash' ), true ) ) {
1317            return new WP_REST_Response( array( 'error' => __( 'Bad request', 'jetpack-forms' ) ), 400 );
1318        }
1319
1320        $status        = $from_status ?? 'trash';
1321        $batch_size    = 1000; // Process in batches to avoid memory issues
1322        $total_deleted = 0;
1323        $has_more      = true;
1324
1325        while ( $has_more ) {
1326            $query_args = array(
1327                'post_type'      => 'feedback',
1328                'post_status'    => $status,
1329                'posts_per_page' => $batch_size,
1330                'fields'         => 'ids', // Only get IDs to reduce memory usage
1331            );
1332
1333            $query    = new \WP_Query( $query_args );
1334            $post_ids = $query->get_posts();
1335
1336            if ( empty( $post_ids ) ) {
1337                $has_more = false;
1338
1339                break;
1340            }
1341
1342            foreach ( $post_ids as $post_id ) {
1343                $feedback_deleted = wp_delete_post( $post_id, true );
1344
1345                if ( ! $feedback_deleted ) {
1346                    if ( $status === 'trash' ) {
1347                        return new WP_REST_Response( array( 'error' => __( 'Failed to empty trash.', 'jetpack-forms' ) ), 400 );
1348                    }
1349
1350                    if ( $status === 'spam' ) {
1351                        return new WP_REST_Response( array( 'error' => __( 'Failed to empty spam.', 'jetpack-forms' ) ), 400 );
1352                    }
1353                }
1354
1355                ++$total_deleted;
1356            }
1357
1358            if ( count( $post_ids ) < $batch_size ) {
1359                $has_more = false;
1360            }
1361        }
1362
1363        return new WP_REST_Response( array( 'deleted' => $total_deleted ), 200 );
1364    }
1365
1366    /**
1367     * Performs the Akismet action to mark all feedback posts matching the given IDs as spam.
1368     *
1369     * @param  array $post_ids Array of post IDs.
1370     * @return WP_REST_Response
1371     */
1372    private function bulk_action_mark_as_spam( $post_ids ) {
1373        foreach ( $post_ids as $post_id ) {
1374            /** This action is documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
1375            do_action(
1376                'contact_form_akismet',
1377                'spam',
1378                get_post_meta( $post_id, '_feedback_akismet_values', true )
1379            );
1380        }
1381        return new WP_REST_Response( array(), 200 );
1382    }
1383
1384    /**
1385     * Performs the Akismet action to mark all feedback posts matching the given IDs as not spam.
1386     *
1387     * @param  array $post_ids Array of post IDs.
1388     * @return WP_REST_Response
1389     */
1390    private function bulk_action_mark_as_not_spam( $post_ids ) {
1391        foreach ( $post_ids as $post_id ) {
1392            /** This action is documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
1393            do_action(
1394                'contact_form_akismet',
1395                'ham',
1396                get_post_meta( $post_id, '_feedback_akismet_values', true )
1397            );
1398        }
1399        return new WP_REST_Response( array(), 200 );
1400    }
1401
1402    /**
1403     * Check whether a given request has proper authorization to view and edit feedback items.
1404     *
1405     * @param  WP_REST_Request $request Full details about the request.
1406     * @return WP_Error|boolean
1407     */
1408    public function get_items_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1409        if ( is_super_admin() ) {
1410            return true;
1411        }
1412
1413        if ( ! current_user_can( 'edit_pages' ) ) {
1414            return false;
1415        }
1416
1417        if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1418            return new WP_Error(
1419                'rest_cannot_view',
1420                esc_html__( 'Sorry, you cannot view this resource.', 'jetpack-forms' ),
1421                array( 'status' => 401 )
1422            );
1423        }
1424
1425        return true;
1426    }
1427
1428    /**
1429     * Check whether a given request has proper authorization to delete feedback items.
1430     *
1431     * @param  WP_REST_Request $request Full details about the request.
1432     * @return WP_Error|boolean
1433     */
1434    public function delete_items_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1435        if ( is_super_admin() ) {
1436            return true;
1437        }
1438
1439        if ( ! current_user_can( 'delete_others_pages' ) ) {
1440            return false;
1441        }
1442
1443        if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1444            return new WP_Error(
1445                'rest_user_cannot_delete_post',
1446                esc_html__( 'Sorry, you cannot delete this resource.', 'jetpack-forms' ),
1447                array( 'status' => 401 )
1448            );
1449        }
1450
1451        return true;
1452    }
1453
1454    /**
1455     * Check whether a given request has proper authorization to view feedback item.
1456     *
1457     * @param  WP_REST_Request $request Full details about the request.
1458     * @return WP_Error|boolean
1459     */
1460    public function get_item_permissions_check( $request ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1461        if ( ! current_user_can( 'edit_pages' ) ) {
1462            return false;
1463        }
1464        if ( ! is_user_member_of_blog( get_current_user_id(), get_current_blog_id() ) ) {
1465            return new WP_Error(
1466                'rest_cannot_view',
1467                esc_html__( 'Sorry, you cannot view this resource.', 'jetpack-forms' ),
1468                array( 'status' => 401 )
1469            );
1470        }
1471
1472        return true;
1473    }
1474
1475    /**
1476     * Get static metadata for an integration (without status checks).
1477     *
1478     * @param string $slug Integration slug.
1479     * @param array  $config Integration configuration.
1480     * @return array Integration metadata.
1481     */
1482    private function get_integration_metadata_fields( $slug, $config ) {
1483        $type                    = $config['type'] ?? null;
1484        $marketing_redirect_slug = $config['marketing_redirect_slug'] ?? null;
1485        $icon_url                = $config['icon_url'] ?? null;
1486
1487        return array(
1488            'id'               => $slug,
1489            'slug'             => $slug,
1490            'type'             => $type,
1491            'title'            => isset( $config['title'] ) ? sanitize_text_field( $config['title'] ) : '',
1492            'subtitle'         => isset( $config['subtitle'] ) ? sanitize_text_field( $config['subtitle'] ) : '',
1493            'marketingUrl'     => $marketing_redirect_slug ? Redirect::get_url( $marketing_redirect_slug ) : null,
1494            'enabledByDefault' => isset( $config['enabled_by_default'] ) ? (bool) $config['enabled_by_default'] : false,
1495            'iconUrl'          => $icon_url ? esc_url_raw( $icon_url ) : null,
1496            'activeTooltip'    => isset( $config['active_tooltip'] ) ? sanitize_text_field( $config['active_tooltip'] ) : '',
1497        );
1498    }
1499
1500    /**
1501     * Core logic for a single integration
1502     *
1503     * @param string $slug Integration slug.
1504     * @return array Integration status data.
1505     */
1506    private function get_integration( $slug ) {
1507        $config = $this->get_supported_integrations()[ $slug ];
1508        $type   = $config['type'] ?? null;
1509
1510        // Start with metadata fields
1511        $base = $this->get_integration_metadata_fields( $slug, $config );
1512
1513        // Add status fields that require checks
1514        $base['pluginFile']      = ( $type === 'plugin' && ! empty( $config['file'] ) ) ? str_replace( '.php', '', $config['file'] ) : null;
1515        $base['isInstalled']     = false;
1516        $base['isActive']        = false;
1517        $base['needsConnection'] = ( $type === 'service' );
1518        $base['isConnected']     = false;
1519        $base['version']         = null;
1520        $base['settingsUrl']     = null;
1521        $base['details']         = array();
1522
1523        // Override base shape based on integration type.
1524        $status = $type === 'plugin'
1525            ? $this->get_plugin_status( $slug, $base )
1526            : $this->get_service_status( $slug, $base );
1527
1528        return $status;
1529    }
1530
1531    /**
1532     * REST callback for /integrations/{slug}
1533     *
1534     * @param WP_REST_Request $request Request object.
1535     * @return WP_REST_Response|WP_Error Response object or error.
1536     */
1537    public function get_single_integration_status( $request ) {
1538        $slug         = $request->get_param( 'slug' );
1539        $integrations = $this->get_supported_integrations();
1540        if ( ! isset( $integrations[ $slug ] ) ) {
1541            return new \WP_Error( 'rest_integration_not_found', __( 'Integration not found.', 'jetpack-forms' ), array( 'status' => 404 ) );
1542        }
1543        return rest_ensure_response( $this->get_integration( $slug ) );
1544    }
1545
1546    /**
1547     * REST callback for /integrations
1548     *
1549     * @param WP_REST_Request $request Request object.
1550     * @return WP_REST_Response Response object.
1551     */
1552    public function get_all_integrations_status( $request ) {
1553        $version      = absint( $request->get_param( 'version' ) );
1554        $integrations = array();
1555
1556        foreach ( $this->get_supported_integrations() as $slug => $config ) {
1557            $status = $this->get_integration( $slug );
1558            if ( 1 === $version ) {
1559                $integrations[ $slug ] = $status;
1560            } else {
1561                $integrations[] = $status;
1562            }
1563        }
1564
1565        return rest_ensure_response( $integrations );
1566    }
1567
1568    /**
1569     * REST callback for /integrations-metadata
1570     *
1571     * Returns only static metadata (name, description, type, etc.) without making
1572     * expensive calls to check connection status or plugin installation status.
1573     * This endpoint is designed to be fast and suitable for preloading.
1574     *
1575     * Uses the same field generation logic as get_integration() to ensure consistency.
1576     *
1577     * @param WP_REST_Request $request Request object.
1578     * @return WP_REST_Response Response object.
1579     */
1580    public function get_integrations_metadata( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1581        $integrations = array();
1582
1583        foreach ( $this->get_supported_integrations() as $slug => $config ) {
1584            $integrations[] = $this->get_integration_metadata_fields( $slug, $config );
1585        }
1586
1587        return rest_ensure_response( $integrations );
1588    }
1589
1590    /**
1591     * Get status for internal/service integrations.
1592     *
1593     * @param string $slug   Service slug.
1594     * @param array  $status Base status shape to mutate and return.
1595     * @return array Service status data.
1596     */
1597    private function get_service_status( $slug, array $status ) {
1598        $config = $this->get_supported_integrations()[ $slug ];
1599
1600        // If a settings redirect slug/url is configured, convert to full URL
1601        if ( ! empty( $config['settings_url'] ) ) {
1602            $status['settingsUrl'] = esc_url( Redirect::get_url( $config['settings_url'] ) );
1603        }
1604
1605        // Override base shape for specific services.
1606        switch ( $slug ) {
1607            case 'google-drive':
1608                $user_id               = get_current_user_id();
1609                $jetpack_connected     = ( new Host() )->is_wpcom_simple() || ( new Connection_Manager( 'jetpack-forms' ) )->is_user_connected( $user_id );
1610                $status['isConnected'] = $jetpack_connected && Google_Drive::has_valid_connection();
1611                $status['settingsUrl'] = External_Connections::get_connect_url( $slug );
1612                break;
1613            case 'salesforce':
1614                // No overrides needed for now; keep defaults.
1615                break;
1616            // Add other service cases as needed.
1617        }
1618
1619        return $status;
1620    }
1621
1622    /**
1623     * Get plugin status.
1624     *
1625     * @param string $plugin_slug Plugin slug.
1626     * @param array  $status      Base status shape to mutate and return.
1627     * @return array Plugin status data.
1628     */
1629    private function get_plugin_status( $plugin_slug, array $status ) {
1630        if ( ! function_exists( 'get_plugins' ) ) {
1631            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1632        }
1633
1634        $integrations  = $this->get_supported_integrations();
1635        $plugin_config = $integrations[ $plugin_slug ];
1636
1637        $installed_plugins = get_plugins();
1638        $is_installed      = isset( $installed_plugins[ $plugin_config['file'] ] );
1639        $is_active         = is_plugin_active( $plugin_config['file'] );
1640
1641        // Override base shape for all plugins.
1642        $status['pluginFile']  = str_replace( '.php', '', $plugin_config['file'] );
1643        $status['isInstalled'] = $is_installed;
1644        $status['isActive']    = $is_active;
1645        $status['version']     = $is_installed ? $installed_plugins[ $plugin_config['file'] ]['Version'] : null;
1646        $status['settingsUrl'] = ( $is_active && ! empty( $plugin_config['settings_url'] ) )
1647            ? admin_url( $plugin_config['settings_url'] )
1648            : null;
1649
1650        // Override base shape for specific plugins.
1651        switch ( $plugin_slug ) {
1652            case 'akismet':
1653                $status['isConnected']                       = class_exists( 'Jetpack' ) && \Jetpack::is_akismet_active();
1654                $status['details']['formSubmissionsSpamUrl'] = Forms_Dashboard::get_forms_admin_url( 'spam' );
1655                $status['needsConnection']                   = true;
1656                break;
1657            case 'zero-bs-crm':
1658                if ( $is_active ) {
1659                    $has_extension     = function_exists( 'zeroBSCRM_isExtensionInstalled' ) && zeroBSCRM_isExtensionInstalled( 'jetpackforms' ); // @phan-suppress-current-line PhanUndeclaredFunction -- We're checking the function exists first
1660                    $status['details'] = array(
1661                        'hasExtension'         => $has_extension,
1662                        'canActivateExtension' => current_user_can( 'manage_options' ),
1663                    );
1664                }
1665                break;
1666            case 'mailpoet':
1667                $status['needsConnection'] = true;
1668                // Determine if MailPoet setup is complete using the public API.
1669                if ( class_exists( \MailPoet\API\API::class ) ) { // @phan-suppress-current-line PhanUndeclaredClassReference
1670                    $mailpoet_api = \MailPoet\API\API::MP( 'v1' ); // @phan-suppress-current-line PhanUndeclaredClassMethod
1671                    if ( $mailpoet_api && method_exists( $mailpoet_api, 'isSetupComplete' ) ) {
1672                        $status['isConnected'] = (bool) $mailpoet_api->isSetupComplete();
1673                    }
1674                }
1675                // Add MailPoet lists to details
1676                $status['details']['lists'] = MailPoet_Integration::get_all_lists();
1677                break;
1678            case 'hostinger-reach':
1679                // Hostinger Reach is a plugin that requires additional setup/connection.
1680                $status['needsConnection'] = true;
1681                $status['isConnected']     = false;
1682                // Determine if Hostinger Reach is connected using its public handler.
1683                if ( $is_active
1684                    // @phan-suppress-next-line PhanUndeclaredClassReference
1685                    && class_exists( \Hostinger\Reach\Api\Handlers\ReachApiHandler::class )
1686                    // @phan-suppress-next-line PhanUndeclaredClassReference
1687                    && class_exists( \Hostinger\Reach\Functions::class )
1688                    // @phan-suppress-next-line PhanUndeclaredClassReference
1689                    && class_exists( \Hostinger\Reach\Api\ApiKeyManager::class )
1690                ) {
1691                    $reach_handler = new \Hostinger\Reach\Api\Handlers\ReachApiHandler( // @phan-suppress-current-line PhanUndeclaredClassMethod
1692                        new \Hostinger\Reach\Functions(), // @phan-suppress-current-line PhanUndeclaredClassMethod
1693                        new \Hostinger\Reach\Api\ApiKeyManager() // @phan-suppress-current-line PhanUndeclaredClassMethod
1694                    );
1695                    if ( method_exists( $reach_handler, 'is_connected' ) ) {
1696                        // @phan-suppress-next-line PhanUndeclaredClassMethod
1697                        $status['isConnected'] = (bool) $reach_handler->is_connected();
1698                    }
1699                }
1700                break;
1701        }
1702
1703        return $status;
1704    }
1705
1706    /**
1707     * REST callback for DELETE /integrations/{slug}
1708     *
1709     * @param WP_REST_Request $request Request object.
1710     * @return WP_REST_Response|WP_Error Response object or error.
1711     */
1712    public function disable_integration( $request ) {
1713        $slug         = $request->get_param( 'slug' );
1714        $integrations = $this->get_supported_integrations();
1715        if ( ! isset( $integrations[ $slug ] ) ) {
1716            return new \WP_Error( 'rest_integration_not_found', __( 'Integration not found.', 'jetpack-forms' ), array( 'status' => 404 ) );
1717        }
1718        if ( $slug !== 'google-drive' ) {
1719            return new \WP_Error( 'rest_integration_invalid', __( 'This integration cannot be disabled.', 'jetpack-forms' ), array( 'status' => 404 ) );
1720        }
1721        $is_deleted = External_Connections::delete_connection( $slug );
1722        return rest_ensure_response( array( 'deleted' => $is_deleted ) );
1723    }
1724
1725    /**
1726     * Updates the read/unread status of a feedback item.
1727     *
1728     * @param WP_REST_Request $request Request object.
1729     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
1730     */
1731    public function update_read_status( $request ) {
1732        $post_id   = $request->get_param( 'id' );
1733        $is_unread = $request->get_param( 'is_unread' );
1734
1735        $feedback_response = Feedback::get( $post_id );
1736        if ( ! $feedback_response ) {
1737            return new WP_Error(
1738                'rest_post_invalid_id',
1739                __( 'Invalid feedback ID.', 'jetpack-forms' ),
1740                array( 'status' => 404 )
1741            );
1742        }
1743
1744        $success = $is_unread ? $feedback_response->mark_as_unread() : $feedback_response->mark_as_read();
1745
1746        Contact_Form_Plugin::recalculate_unread_count();
1747        if ( ! $success ) {
1748            return new WP_Error(
1749                'rest_cannot_update',
1750                __( 'Failed to update feedback read status.', 'jetpack-forms' ),
1751                array( 'status' => 500 )
1752            );
1753        }
1754
1755        return rest_ensure_response(
1756            array(
1757                'id'        => $post_id,
1758                'is_unread' => $feedback_response->is_unread(),
1759                'count'     => Contact_Form_Plugin::get_unread_count(),
1760            )
1761        );
1762    }
1763
1764    /**
1765     * Dismiss the classic forms notice by updating the option to 'dismissed'.
1766     *
1767     * @return WP_REST_Response
1768     */
1769    public function dismiss_classic_forms_notice() {
1770        update_option( Forms_Dashboard::CLASSIC_FORMS_OPTION, Forms_Dashboard::CLASSIC_FORMS_STATE_DISMISSED, false );
1771
1772        return rest_ensure_response( array( 'success' => true ) );
1773    }
1774
1775    /**
1776     * Return consolidated Forms config payload.
1777     *
1778     * @param WP_REST_Request $request Request.
1779     * @return WP_REST_Response
1780     */
1781    public function get_forms_config( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1782        $config = array(
1783            // Feature flags.
1784            'isCentralFormManagementEnabled' => Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ),
1785            // From jpFormsBlocks in class-contact-form-block.php.
1786            'formsResponsesUrl'              => Forms_Dashboard::get_forms_admin_url(),
1787            'isMailPoetEnabled'              => Jetpack_Forms::is_mailpoet_enabled(),
1788            'isHostingerReachEnabled'        => Jetpack_Forms::is_hostinger_reach_enabled(),
1789            // From config in class-dashboard.php.
1790            'blogId'                         => get_current_blog_id(),
1791            'gdriveConnectSupportURL'        => esc_url( Redirect::get_url( 'jetpack-support-contact-form-export' ) ),
1792            'pluginAssetsURL'                => Jetpack_Forms::assets_url(),
1793            'fileIconsUrl'                   => Jetpack_Forms::plugin_url() . 'contact-form/images/file-icons/',
1794            'siteURL'                        => ( new Status() )->get_site_suffix(),
1795            'hasFeedback'                    => ( new Forms_Dashboard() )->has_feedback(),
1796            'hasClassicForms'                => ( new Forms_Dashboard() )->get_classic_forms_state() === Forms_Dashboard::CLASSIC_FORMS_STATE_CLASSIC,
1797            'isNotesEnabled'                 => Forms_Dashboard::is_notes_enabled(),
1798            'isIntegrationsEnabled'          => Jetpack_Forms::is_integrations_enabled(),
1799            'isWebhooksEnabled'              => Jetpack_Forms::is_webhooks_enabled(),
1800            'showDashboardIntegrations'      => Jetpack_Forms::show_dashboard_integrations(),
1801            'showBlockIntegrations'          => Jetpack_Forms::show_block_integrations(),
1802            'showIntegrationIcons'           => Jetpack_Forms::show_integration_icons(),
1803            'dashboardURL'                   => Forms_Dashboard::get_forms_admin_url(),
1804            // New data.
1805            'canInstallPlugins'              => current_user_can( 'install_plugins' ),
1806            'canActivatePlugins'             => current_user_can( 'activate_plugins' ),
1807            'exportNonce'                    => wp_create_nonce( 'feedback_export' ),
1808            'newFormNonce'                   => wp_create_nonce( 'create_new_form' ),
1809            'emptyTrashDays'                 => defined( 'EMPTY_TRASH_DAYS' ) ? EMPTY_TRASH_DAYS : 0,
1810            // Admin URLs for external admin contexts.
1811            'adminUrl'                       => admin_url(),
1812            'ajaxUrl'                        => admin_url( 'admin-ajax.php' ),
1813        );
1814
1815        return rest_ensure_response( $config );
1816    }
1817}