Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.37% covered (warning)
64.37%
112 / 174
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Form_Endpoint
64.37% covered (warning)
64.37%
112 / 174
50.00% covered (danger)
50.00%
6 / 12
94.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 get_status_counts
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 get_status_counts_for_author
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 get_preview_url
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 get_collection_params
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 get_items
28.57% covered (danger)
28.57%
8 / 28
0.00% covered (danger)
0.00%
0 / 1
64.48
 get_entries_count_by_form_id
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 filter_by_responses
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 get_items_permissions_check
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 create_item_permissions_check
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 check_read_permission
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Jetpack_Form_Endpoint class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use WP_REST_Request;
11
12/**
13 * REST endpoint for the jetpack_form custom post type.
14 */
15class Jetpack_Form_Endpoint extends \WP_REST_Posts_Controller {
16    /**
17     * Cached map of form_id => entries count for the current request.
18     *
19     * @var array<int,int>|null
20     */
21    private $entries_count_by_form_id = null;
22
23    /**
24     * Whether the current request filters by has_responses.
25     *
26     * @var bool
27     */
28    public $has_responses_filter = true;
29
30    /**
31     * Constructor.
32     */
33    public function __construct() {
34        parent::__construct( Contact_Form::POST_TYPE );
35    }
36
37    /**
38     * Registers the routes for the objects of the controller.
39     */
40    public function register_routes() {
41        parent::register_routes();
42
43        // Register custom preview-url route.
44        register_rest_route(
45            $this->namespace,
46            '/' . $this->rest_base . '/(?P<id>[\d]+)/preview-url',
47            array(
48                'methods'             => \WP_REST_Server::READABLE,
49                'callback'            => array( $this, 'get_preview_url' ),
50                'permission_callback' => array( $this, 'get_item_permissions_check' ),
51                'args'                => array(
52                    'id' => array(
53                        'description'       => __( 'Unique identifier for the form.', 'jetpack-forms' ),
54                        'type'              => 'integer',
55                        'required'          => true,
56                        'sanitize_callback' => 'absint',
57                    ),
58                ),
59            )
60        );
61
62        // Get form status counts.
63        register_rest_route(
64            $this->namespace,
65            '/' . $this->rest_base . '/status-counts',
66            array(
67                'methods'             => \WP_REST_Server::READABLE,
68                'permission_callback' => array( $this, 'get_items_permissions_check' ),
69                'callback'            => array( $this, 'get_status_counts' ),
70            )
71        );
72    }
73
74    /**
75     * Retrieves per-status counts for the jetpack_form post type.
76     *
77     * Users who can edit others' forms (e.g. admins and editors) receive
78     * site-wide counts via wp_count_posts(). Users who cannot (e.g. authors)
79     * receive counts scoped to the forms they authored, so aggregate counts of
80     * other users' forms are not leaked.
81     *
82     * @return \WP_REST_Response Response object with status counts.
83     */
84    public function get_status_counts() {
85        $post_type_object = get_post_type_object( $this->post_type );
86
87        if ( $post_type_object && current_user_can( $post_type_object->cap->edit_others_posts ) ) {
88            $counts = (array) wp_count_posts( Contact_Form::POST_TYPE );
89        } else {
90            $counts = $this->get_status_counts_for_author( get_current_user_id() );
91        }
92
93        $publish = (int) ( $counts['publish'] ?? 0 );
94        $draft   = (int) ( $counts['draft'] ?? 0 );
95        $pending = (int) ( $counts['pending'] ?? 0 );
96        $future  = (int) ( $counts['future'] ?? 0 );
97        $private = (int) ( $counts['private'] ?? 0 );
98        $trash   = (int) ( $counts['trash'] ?? 0 );
99
100        return rest_ensure_response(
101            array(
102                'all'     => $publish + $draft + $pending + $future + $private,
103                'publish' => $publish,
104                'draft'   => $draft,
105                'pending' => $pending,
106                'future'  => $future,
107                'private' => $private,
108                'trash'   => $trash,
109            )
110        );
111    }
112
113    /**
114     * Count forms authored by a specific user, grouped by post status.
115     *
116     * The wp_count_posts() function cannot be scoped by author (its second argument is a
117     * permission level, not query args), so a direct query is used to mirror its
118     * shape while restricting results to a single author. The result is
119     * user-scoped and computed by a single grouped aggregate run once per request
120     * (the dashboard preloads this endpoint), so it is intentionally not cached --
121     * unlike get_entries_count_by_form_id(), whose lookup is shared across forms
122     * and benefits from a short-lived cache.
123     *
124     * @param int $author_id User ID to scope the counts to.
125     * @return array<string,int> Map of post_status => count.
126     */
127    private function get_status_counts_for_author( int $author_id ): array {
128        global $wpdb;
129
130        // Intentionally uncached: the result is user-scoped and computed once per request.
131        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
132        $rows = $wpdb->get_results(
133            $wpdb->prepare(
134                "SELECT post_status, COUNT(1) AS num_posts
135                FROM {$wpdb->posts}
136                WHERE post_type = %s
137                  AND post_author = %d
138                GROUP BY post_status",
139                Contact_Form::POST_TYPE,
140                $author_id
141            )
142        );
143
144        $counts = array();
145        foreach ( (array) $rows as $row ) {
146            $counts[ $row->post_status ] = (int) $row->num_posts;
147        }
148
149        return $counts;
150    }
151
152    /**
153     * Get the preview URL for a form.
154     *
155     * @param WP_REST_Request $request Full details about the request.
156     * @return \WP_REST_Response|\WP_Error Response object or WP_Error.
157     */
158    public function get_preview_url( $request ) {
159        $form_id     = $request->get_param( 'id' );
160        $preview_url = Form_Preview::generate_preview_url( $form_id );
161
162        if ( ! $preview_url ) {
163            return new \WP_Error(
164                'rest_cannot_preview',
165                __( 'Unable to generate preview URL.', 'jetpack-forms' ),
166                array( 'status' => 403 )
167            );
168        }
169
170        return rest_ensure_response( array( 'preview_url' => $preview_url ) );
171    }
172
173    /**
174     * Add opt-in dashboard fields.
175     *
176     * @return array
177     */
178    public function get_collection_params() {
179        $params = parent::get_collection_params();
180
181        // Note: We do not use the built-in WP REST "context" param for this, because it's validated
182        // against core values (view/embed/edit). This param is for Jetpack Forms dashboard usage only.
183        $params['jetpack_forms_context'] = array(
184            'description'       => __( 'Request context for Jetpack Forms. Use "dashboard" to include dashboard-only fields.', 'jetpack-forms' ),
185            'type'              => 'string',
186            'default'           => '',
187            'enum'              => array( '', 'dashboard' ),
188            'sanitize_callback' => 'sanitize_key',
189        );
190
191        $params['has_responses'] = array(
192            'description'       => __( 'Filter forms by whether they have responses. "true" returns only forms with responses, "false" returns only forms without.', 'jetpack-forms' ),
193            'type'              => 'string',
194            'enum'              => array( '', 'true', 'false' ),
195            'default'           => '',
196            'sanitize_callback' => 'sanitize_key',
197        );
198
199        return $params;
200    }
201
202    /**
203     * Return a collection of forms.
204     *
205     * We override this to compute dashboard aggregate fields in a single pass.
206     *
207     * @param WP_REST_Request $request Full details about the request.
208     * @return \WP_REST_Response|\WP_Error
209     */
210    public function get_items( $request ) {
211        $has_responses = (string) $request->get_param( 'has_responses' );
212        if ( '' !== $has_responses ) {
213            $this->has_responses_filter = ( 'true' === $has_responses );
214            add_filter( 'posts_clauses', array( $this, 'filter_by_responses' ), 10, 2 );
215        }
216
217        $response = parent::get_items( $request );
218
219        if ( '' !== $has_responses ) {
220            remove_filter( 'posts_clauses', array( $this, 'filter_by_responses' ), 10 );
221        }
222
223        if ( is_wp_error( $response ) ) {
224            return $response;
225        }
226
227        $forms_context = (string) $request->get_param( 'jetpack_forms_context' );
228        if ( 'dashboard' !== $forms_context ) {
229            return $response;
230        }
231
232        $forms = $response->get_data();
233        if ( ! is_array( $forms ) || empty( $forms ) ) {
234            return $response;
235        }
236
237        $form_ids = array();
238        foreach ( $forms as $form ) {
239            if ( isset( $form['id'] ) ) {
240                $form_ids[] = (int) $form['id'];
241            }
242        }
243        $form_ids = array_values( array_unique( array_filter( $form_ids ) ) );
244
245        $this->entries_count_by_form_id = $this->get_entries_count_by_form_id( $form_ids );
246
247        foreach ( $forms as &$form ) {
248            $form_id               = isset( $form['id'] ) ? (int) $form['id'] : 0;
249            $form['entries_count'] = (int) ( $this->entries_count_by_form_id[ $form_id ] ?? 0 );
250            if ( $form_id ) {
251                $form['edit_url'] = get_edit_post_link( $form_id, 'raw' );
252            }
253        }
254
255        $response->set_data( $forms );
256        return $response;
257    }
258
259    /**
260     * Batch compute feedback counts for a list of form IDs.
261     *
262     * @param int[] $form_ids Form IDs to count entries for.
263     * @return array<int,int> Map of form_id => count
264     */
265    private function get_entries_count_by_form_id( array $form_ids ): array {
266        global $wpdb;
267
268        $form_ids = array_values( array_unique( array_map( 'absint', $form_ids ) ) );
269        if ( empty( $form_ids ) ) {
270            return array();
271        }
272
273        // Count only "inbox-visible" feedback statuses.
274        // Note: This is about feedback (response) statuses, not form post statuses (publish/draft/pending/future/private).
275        $statuses = array( 'publish', 'draft' );
276
277        // Cache the grouped counts briefly to avoid repeated DB hits (e.g. on reload / concurrent requests).
278        sort( $form_ids );
279        $cache_key   = 'feedback_counts_' . md5( implode( ',', $form_ids ) . '|' . implode( ',', $statuses ) );
280        $cache_group = 'jetpack_forms';
281        $cached      = wp_cache_get( $cache_key, $cache_group );
282        if ( false !== $cached && is_array( $cached ) ) {
283            return $cached;
284        }
285
286        $args = array_merge( array( Feedback::POST_TYPE ), $form_ids, $statuses );
287
288        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
289        $rows              = $wpdb->get_results(
290            $wpdb->prepare(
291                "SELECT post_parent, COUNT(1) AS entry_count
292                FROM {$wpdb->posts}
293                WHERE post_type = %s
294                  AND post_parent IN (" . implode( ',', array_fill( 0, count( $form_ids ), '%d' ) ) . ')
295                  AND post_status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')
296                GROUP BY post_parent',
297                $args
298            )
299        );
300        $counts_by_form_id = array();
301        foreach ( (array) $rows as $row ) {
302            $counts_by_form_id[ (int) $row->post_parent ] = (int) $row->entry_count;
303        }
304
305        wp_cache_set( $cache_key, $counts_by_form_id, $cache_group, 15 ); // 15 seconds.
306        return $counts_by_form_id;
307    }
308
309    /**
310     * Filter posts_clauses to include/exclude forms that have feedback responses.
311     *
312     * @param array     $clauses SQL clauses.
313     * @param \WP_Query $query   The current WP_Query instance.
314     * @return array Modified clauses.
315     */
316    public function filter_by_responses( $clauses, $query ) {
317        global $wpdb;
318
319        // Only modify the query for jetpack_form post type.
320        if ( $query->get( 'post_type' ) !== $this->post_type ) {
321            return $clauses;
322        }
323
324        $feedback_type = Feedback::POST_TYPE;
325        $operator      = $this->has_responses_filter ? 'EXISTS' : 'NOT EXISTS';
326
327        $subquery = $wpdb->prepare(
328            "SELECT 1 FROM {$wpdb->posts} AS feedback
329            WHERE feedback.post_parent = {$wpdb->posts}.ID
330            AND feedback.post_type = %s
331            AND feedback.post_status IN (%s, %s)",
332            $feedback_type,
333            'publish',
334            'draft'
335        );
336
337        $clauses['where'] .= " AND $operator ($subquery)";
338
339        return $clauses;
340    }
341
342    /**
343     * Checks if a given request has access to get items.
344     *
345     * @param \WP_REST_Request $request Full details about the request.
346     * @return true|\WP_Error True if the request has read access, WP_Error object otherwise.
347     */
348    public function get_items_permissions_check( $request ) {
349        $post_type = get_post_type_object( $this->post_type );
350
351        if ( ! current_user_can( $post_type->cap->edit_posts ) ) {
352            return new \WP_Error(
353                'rest_cannot_read',
354                __( 'Sorry, you are not allowed to view forms.', 'jetpack-forms' ),
355                array( 'status' => rest_authorization_required_code() )
356            );
357        }
358
359        return parent::get_items_permissions_check( $request );
360    }
361
362    /**
363     * Checks if a given request has access to create items.
364     *
365     * @param \WP_REST_Request $request Full details about the request.
366     * @return true|\WP_Error True if the request has access to create items, WP_Error object otherwise.
367     */
368    public function create_item_permissions_check( $request ) {
369        $post_type = get_post_type_object( $this->post_type );
370
371        if ( ! current_user_can( $post_type->cap->create_posts ) ) {
372            return new \WP_Error(
373                'rest_cannot_create',
374                __( 'Sorry, you are not allowed to create forms.', 'jetpack-forms' ),
375                array( 'status' => rest_authorization_required_code() )
376            );
377        }
378
379        return parent::create_item_permissions_check( $request );
380    }
381
382    /**
383     * Checks if a jetpack-form can be read.
384     *
385     * @param \WP_Post $post Post object that backs the block.
386     * @return bool Whether the pattern can be read.
387     */
388    public function check_read_permission( $post ) {
389        // By default the read_post capability is mapped to edit_posts.
390        if ( ! current_user_can( 'read_post', $post->ID ) ) {
391            return false;
392        }
393
394        return parent::check_read_permission( $post );
395    }
396}