Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add source filter parameter to feedback REST API for filtering responses by source post ID, with fallback to post_parent for legacy data.
3 changes: 2 additions & 1 deletion projects/packages/forms/routes/responses/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type QueryParams = {
order?: string;
is_unread?: boolean;
parent?: string;
source?: string;
before?: string;
after?: string;
search?: string;
Expand Down Expand Up @@ -305,7 +306,7 @@ function StageInner() {
queryArgs.is_unread = filter.value === 'unread';
}
if ( ! isSingleFormView && filter.field === 'source' ) {
queryArgs.parent = filter.value;
queryArgs.source = filter.value;
}
if ( filter.field === 'date' ) {
const [ year, month ] = filter.value.split( '/' ).map( Number );
Expand Down
3 changes: 3 additions & 0 deletions projects/packages/forms/src/class-jetpack-forms.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public static function load_contact_form() {
// Add hook to delete file attachments when a feedback post is deleted
add_action( 'before_delete_post', array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form', 'delete_feedback_files' ) );

// Invalidate the source post IDs cache when a feedback post is permanently deleted.
add_action( 'deleted_post', array( '\Automattic\Jetpack\Forms\ContactForm\Feedback', 'invalidate_source_ids_cache_on_delete' ), 10, 2 );

// Enforces the availability of block support controls in the UI for classic themes.
add_filter( 'wp_theme_json_data_default', array( '\Automattic\Jetpack\Forms\ContactForm\Contact_Form', 'add_theme_json_data_for_classic_themes' ) );

Expand Down
123 changes: 107 additions & 16 deletions projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
*/
class Contact_Form_Endpoint extends \WP_REST_Posts_Controller {

/**
* Temporary storage for the source filter ID used in query modifications.
*
* @var int|null
*/
private $temp_source_filter_id;

/**
* Get filtered list of supported integrations
*
Expand Down Expand Up @@ -359,6 +366,12 @@ public function register_routes() {
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
),
'source' => array(
'description' => 'Limit results to feedback submitted from a specific source post ID.',
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
),
),
)
);
Expand Down Expand Up @@ -414,8 +427,6 @@ static function ( $post ) {
* @return WP_REST_Response Response object on success.
*/
public function get_filters() {
// TODO: investigate how we can do this better regarding usage of $wpdb
// performance by querying all the entities, etc..
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$months = $wpdb->get_results(
Expand All @@ -424,10 +435,9 @@ public function get_filters() {
WHERE post_type = 'feedback'
ORDER BY post_date DESC"
);

$source_ids = Feedback::get_all_source_post_ids();
// phpcs:enable
$source_ids = Contact_Form_Plugin::get_all_parent_post_ids(
array_diff_key( array( 'post_status' => array( 'draft', 'publish', 'spam', 'trash' ) ), array( 'post_parent' => '' ) )
);

return rest_ensure_response(
array(
Expand Down Expand Up @@ -456,32 +466,40 @@ public function get_status_counts( $request ) {

$search = $request->get_param( 'search' );
$parent = $request->get_param( 'parent' );
$source = $request->get_param( 'source' );
$before = $request->get_param( 'before' );
$after = $request->get_param( 'after' );
$is_unread = $request->get_param( 'is_unread' );

$where_conditions = array( $wpdb->prepare( 'post_type = %s', 'feedback' ) );
$join_clause = '';
$where_conditions = array( $wpdb->prepare( "{$wpdb->posts}.post_type = %s", 'feedback' ) );

if ( ! empty( $search ) ) {
$search_like = '%' . $wpdb->esc_like( $search ) . '%';
$where_conditions[] = $wpdb->prepare( '(post_title LIKE %s OR post_content LIKE %s)', $search_like, $search_like );
$where_conditions[] = $wpdb->prepare( "({$wpdb->posts}.post_title LIKE %s OR {$wpdb->posts}.post_content LIKE %s)", $search_like, $search_like );
}

if ( ! empty( $parent ) ) {
$where_conditions[] = $wpdb->prepare( 'post_parent = %d', $parent );
$where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.post_parent = %d", $parent );
}

if ( ! empty( $source ) ) {
$source_sql = $this->get_source_filter_sql( absint( $source ) );
$join_clause .= $source_sql['join'];
$where_conditions[] = $source_sql['where'];
}

if ( ! empty( $before ) ) {
$where_conditions[] = $wpdb->prepare( 'post_date <= %s', $before );
$where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.post_date <= %s", $before );
}

if ( ! empty( $after ) ) {
$where_conditions[] = $wpdb->prepare( 'post_date >= %s', $after );
$where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.post_date >= %s", $after );
}

if ( null !== $is_unread ) {
$comment_status = $is_unread ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
$where_conditions[] = $wpdb->prepare( 'comment_status = %s', $comment_status );
$where_conditions[] = $wpdb->prepare( "{$wpdb->posts}.comment_status = %s", $comment_status );
}

$where_clause = implode( ' AND ', $where_conditions );
Expand All @@ -490,11 +508,12 @@ public function get_status_counts( $request ) {
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$counts = $wpdb->get_row(
"SELECT
SUM(CASE WHEN post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox,
SUM(CASE WHEN post_status = 'spam' THEN 1 ELSE 0 END) as spam,
SUM(CASE WHEN post_status = 'trash' THEN 1 ELSE 0 END) as trash
FROM $wpdb->posts
WHERE $where_clause",
SUM(CASE WHEN {$wpdb->posts}.post_status IN ('publish', 'draft') THEN 1 ELSE 0 END) as inbox,
SUM(CASE WHEN {$wpdb->posts}.post_status = 'spam' THEN 1 ELSE 0 END) as spam,
SUM(CASE WHEN {$wpdb->posts}.post_status = 'trash' THEN 1 ELSE 0 END) as trash
FROM {$wpdb->posts}
{$join_clause}
WHERE {$where_clause}",
ARRAY_A
);
// phpcs:enable
Expand Down Expand Up @@ -885,6 +904,9 @@ public function prepare_item_for_response( $item, $request ) {
return rest_ensure_response( $data );
}

// Lazily backfill source meta for old feedback that doesn't have it yet.
Feedback::maybe_backfill_source_meta( $item->ID, $feedback_response );

$data['date'] = get_the_date( 'c', $data['id'] );
if ( rest_is_field_included( 'uid', $fields ) ) {
$data['uid'] = $feedback_response->get_feedback_id();
Expand Down Expand Up @@ -988,6 +1010,11 @@ public function get_items( $request ) {
remove_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10 );
unset( $this->temp_invalid_ids );
}
if ( ! empty( $this->temp_source_filter_id ) ) {
remove_filter( 'posts_join', array( $this, 'join_source_meta' ), 10 );
remove_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10 );
$this->temp_source_filter_id = null;
}

return $response;
}
Expand Down Expand Up @@ -1022,6 +1049,28 @@ public function modify_query_for_invalid_ids( $where, $query ) {
return $where;
}

/**
* Returns the JOIN and WHERE SQL fragments for filtering by source post ID.
*
* Matches feedback with the _feedback_source_post_id meta set, or falls back
* to post_parent for old feedback that doesn't have the meta yet.
*
* @param int $source_id The source post ID to filter by.
* @return array{join: string, where: string} SQL fragments.
*/
private function get_source_filter_sql( $source_id ) {
global $wpdb;
$meta_key = esc_sql( Feedback::SOURCE_META_KEY );
return array(
'join' => " LEFT JOIN {$wpdb->postmeta} AS source_meta ON ({$wpdb->posts}.ID = source_meta.post_id AND source_meta.meta_key = '{$meta_key}')",
'where' => $wpdb->prepare(
"(source_meta.meta_value = %s OR (source_meta.meta_id IS NULL AND {$wpdb->posts}.post_parent = %d))",
(string) $source_id,
$source_id
),
);
}

/**
* Filters the query arguments for the feedback collection.
*
Expand All @@ -1036,9 +1085,47 @@ protected function prepare_items_query( $args = array(), $request = null ) {
$args['comment_status'] = $request['is_unread'] ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
}

// Filter by source post ID using meta (with fallback to post_parent for old data).
$source = $request->get_param( 'source' );
if ( ! empty( $source ) ) {
$this->temp_source_filter_id = absint( $source );
add_filter( 'posts_join', array( $this, 'join_source_meta' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'filter_by_source_id' ), 10, 2 );
}

return $args;
}

/**
* Joins the postmeta table for source filtering.
*
* @param string $join The JOIN clause.
* @param WP_Query $query The WP_Query instance.
* @return string Modified JOIN clause.
*/
public function join_source_meta( $join, $query ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by posts_join filter signature.
if ( empty( $this->temp_source_filter_id ) ) {
return $join;
}
$sql = $this->get_source_filter_sql( $this->temp_source_filter_id );
return $join . $sql['join'];
}

/**
* Filters feedback by source post ID, using meta with fallback to post_parent for old data.
*
* @param string $where The WHERE clause.
* @param WP_Query $query The WP_Query instance.
* @return string Modified WHERE clause.
*/
public function filter_by_source_id( $where, $query ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by posts_where filter signature.
if ( empty( $this->temp_source_filter_id ) ) {
return $where;
}
$sql = $this->get_source_filter_sql( $this->temp_source_filter_id );
return $where . ' AND ' . $sql['where'];
}
Comment thread
enejb marked this conversation as resolved.
Outdated

/**
* Retrieves the query params for the feedback collection.
*
Expand All @@ -1065,6 +1152,10 @@ public function get_collection_params() {
),
'default' => array(),
);
$query_params['source'] = array(
'description' => __( 'Limit result set to feedback submitted from a particular source post ID.', 'jetpack-forms' ),
'type' => 'integer',
);
$query_params['is_unread'] = array(
'description' => __( 'Limit result set to read or unread feedback items.', 'jetpack-forms' ),
'type' => 'boolean',
Expand Down
123 changes: 123 additions & 0 deletions projects/packages/forms/src/contact-form/class-feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,122 @@ class Feedback {
*/
public const STATUS_READ = 'closed';

/**
* Meta key used to store the source post ID on feedback posts.
*
* @var string
*/
public const SOURCE_META_KEY = '_feedback_source_post_id';

/**
* Cache key for the source post IDs list.
*
* @var string
*/
private const SOURCE_IDS_CACHE_KEY = 'jetpack_forms_source_post_ids';

/**
* Cache group for forms data.
*
* @var string
*/
private const CACHE_GROUP = 'jetpack_forms';

/**
* Returns all distinct source post IDs for feedback entries.
*
* Uses the _feedback_source_post_id meta for new feedback, with a fallback
* to post_parent for old feedback that doesn't have the meta yet (excluding
* jetpack_form parents).
*
* @return array Array of unique source post IDs.
*/
public static function get_all_source_post_ids() {
$source_ids = wp_cache_get( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object cache with no TTL: if any invalidation path is missed, the cache becomes permanently stale. A safety-net TTL (e.g., HOUR_IN_SECONDS) would prevent that:

wp_cache_set( self::SOURCE_IDS_CACHE_KEY, $source_ids, self::CACHE_GROUP, HOUR_IN_SECONDS );


if ( false !== $source_ids ) {
return $source_ids;
}

global $wpdb;

$meta_key = self::SOURCE_META_KEY;
$statuses = array( 'draft', 'publish', 'spam', 'trash' );
$placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) );

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$source_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT source_id FROM (
SELECT CAST(pm.meta_value AS UNSIGNED) AS source_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = %s
AND p.post_type = 'feedback'
AND p.post_status IN ({$placeholders})
AND pm.meta_value != '0' AND pm.meta_value != ''
UNION
SELECT p.post_parent AS source_id
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
LEFT JOIN {$wpdb->posts} parent_post ON parent_post.ID = p.post_parent
WHERE p.post_type = 'feedback'
AND p.post_status IN ({$placeholders})
AND p.post_parent > 0
AND pm.meta_id IS NULL
AND (parent_post.post_type IS NULL OR parent_post.post_type != %s)
) AS combined_sources",
array_merge(
array( $meta_key ),
$statuses,
array( $meta_key ),
$statuses,
array( Contact_Form::POST_TYPE )
)
)
);
// phpcs:enable

$source_ids = array_map( 'intval', $source_ids );
wp_cache_set( self::SOURCE_IDS_CACHE_KEY, $source_ids, self::CACHE_GROUP );

return $source_ids;
}

/**
* Invalidates the source post IDs cache when a feedback post is deleted.
*
* @param int $post_id The deleted post ID.
* @param \WP_Post $post The deleted post object.
*/
public static function invalidate_source_ids_cache_on_delete( $post_id, $post ) {
if ( $post->post_type === self::POST_TYPE ) {
wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
}
}

/**
* Backfills the source post ID meta from the feedback object's resolved source.
*
* For old feedback parented to a jetpack_form that doesn't have
* _feedback_source_post_id set yet, this writes the meta so future
* queries can filter by source without the post_parent fallback.
*
* @param int $post_id The feedback post ID.
* @param Feedback $feedback The feedback object (already has source resolved from parsed content).
*/
public static function maybe_backfill_source_meta( $post_id, $feedback ) {
$existing = get_post_meta( $post_id, self::SOURCE_META_KEY, true );
if ( $existing ) {
return;
}

$source_id = $feedback->get_entry_id();
if ( is_numeric( $source_id ) && (int) $source_id > 0 ) {
add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
Comment thread
enejb marked this conversation as resolved.
Outdated
}
}

/**
* The form field values.
*
Expand Down Expand Up @@ -1345,6 +1461,13 @@ public function save() {
)
);

// Store source post ID as meta for queryable source filtering.
$source_id = $this->source->get_id();
if ( is_numeric( $post_id ) && (int) $post_id > 0 && is_numeric( $source_id ) && (int) $source_id > 0 ) {
add_post_meta( $post_id, self::SOURCE_META_KEY, (int) $source_id, true );
wp_cache_delete( self::SOURCE_IDS_CACHE_KEY, self::CACHE_GROUP );
}

// If this feedback does not have a jetpack_form parent,
// it's a classic form — mark the state accordingly.
if ( empty( $this->form_id ) ) {
Expand Down
Loading
Loading