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