query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'transient_psa_search_%' OR option_name LIKE 'transient_timeout_psa_search_%'" ); } /** * Register REST API routes for external/headless access. * Note: REST route only returns existing published results — it does NOT trigger generation. */ public static function register_rest_routes() { register_rest_route( 'peptides/v1', '/search', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'rest_search' ), 'permission_callback' => '__return_true', 'args' => array( 'q' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ( $param ) { return is_string( $param ) && strlen( $param ) >= PSA_Config::MIN_QUERY_LENGTH; }, ), ), ) ); } /** * REST search callback — returns published peptides only (no generation trigger). * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response */ public static function rest_search( $request ) { // Rate limiting: max REST_RATE_LIMIT requests per IP per hour. $ip = psa_get_client_ip(); $rate_key = 'psa_rest_rate_' . md5( $ip ); $count = (int) get_transient( $rate_key ); if ( $count >= PSA_Config::REST_RATE_LIMIT ) { return new WP_REST_Response( array( 'error' => 'Rate limit exceeded. Please try again later.' ), 429 ); } set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS ); $query = $request->get_param( 'q' ); // Max input length validation. if ( mb_strlen( $query ) > PSA_Config::MAX_QUERY_LENGTH ) { return new WP_REST_Response( array( 'error' => 'Search query must not exceed ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ), 400 ); } $results = self::search_peptides( $query ); return new WP_REST_Response( $results, 200 ); } /** * Render the [peptide_search] shortcode. * * Uses class-based selectors (no IDs) to support multiple shortcode instances * on the same page (e.g., hero section + search overlay). Each instance is * independently functional via scoped JS initialization. * * @param array $atts Shortcode attributes. * @return string HTML output. */ public static function render_search_form( $atts ) { self::$instance_count++; $instance = self::$instance_count; $atts = shortcode_atts( array( 'placeholder' => 'Search for a peptide (e.g., BPC-157, Thymosin Beta-4)...', ), $atts, 'peptide_search' ); ob_start(); ?>
PSA_Config::MAX_QUERY_LENGTH ) { wp_send_json_error( 'Search query must be between ' . PSA_Config::MIN_QUERY_LENGTH . ' and ' . PSA_Config::MAX_QUERY_LENGTH . ' characters.' ); } // 1. Search existing published peptides. $results = self::search_peptides( $query ); if ( ! empty( $results['results'] ) ) { wp_send_json_success( array( 'status' => 'found', 'data' => $results, ) ); return; } // 2. Check if already pending — handles retries if timed out. $pending = self::find_pending_peptide( $query ); if ( $pending ) { self::handle_pending_retry( $pending ); return; // handle_pending_retry() exits with wp_send_json_success() } // 3. Check rate limit before attempting new generation. if ( ! self::check_rate_limit() ) { wp_send_json_success( array( 'status' => 'rate_limited', 'message' => 'Too many requests. Please try again later.', ) ); return; } // 4. Validate and generate. self::handle_new_generation( $query ); return; // handle_new_generation() exits with wp_send_json_*() } /** * Handle a pending peptide — retry if timed out, else return pending status. * * @param WP_Post $pending The pending post. * @return void Exits with wp_send_json_success(). */ private static function handle_pending_retry( $pending ) { $started = (int) get_post_meta( $pending->ID, 'psa_generation_started', true ); $retry_count = (int) get_post_meta( $pending->ID, 'psa_generation_attempts', true ); $timed_out = $started && ( time() - $started ) > PSA_Config::PENDING_TIMEOUT; if ( $timed_out ) { if ( $retry_count >= PSA_Config::MAX_GENERATION_RETRIES ) { // Max retries exceeded — mark as failed. error_log( 'PSA: Max retries (' . PSA_Config::MAX_GENERATION_RETRIES . ') exceeded for "' . $pending->post_title . '" (post ' . $pending->ID . '). Marking as failed.' ); update_post_meta( $pending->ID, 'psa_source', 'failed' ); update_post_meta( $pending->ID, 'psa_generation_error', 'Generation failed after ' . PSA_Config::MAX_GENERATION_RETRIES . ' attempts.' ); wp_send_json_success( array( 'status' => 'invalid', 'message' => 'We were unable to generate content for this peptide. Please try again later.', ) ); } // Retry generation. error_log( 'PSA: Retrying stale pending peptide ' . $pending->post_title . ' (post ' . $pending->ID . ', attempt ' . ( $retry_count + 1 ) . '/' . PSA_Config::MAX_GENERATION_RETRIES . ')' ); update_post_meta( $pending->ID, 'psa_generation_started', time() ); update_post_meta( $pending->ID, 'psa_generation_attempts', $retry_count + 1 ); self::schedule_background_generation( $pending->ID, $pending->post_title ); } wp_send_json_success( array( 'status' => 'pending', 'peptide_name' => $pending->post_title, ) ); } /** * Check rate limit for AJAX generation requests. * * @return bool True if under limit, false if limit exceeded. */ private static function check_rate_limit() { $ip = psa_get_client_ip(); $rate_key = 'psa_rate_' . md5( $ip ); $count = (int) get_transient( $rate_key ); return $count < PSA_Config::AJAX_RATE_LIMIT; } /** * Handle a new generation request — validate, create placeholder, and schedule generation. * * @param string $query The search query (peptide name). * @return void Exits with wp_send_json_* responses. */ private static function handle_new_generation( $query ) { // Validate peptide name via AI. $validation = PSA_AI_Generator::validate_peptide_name( $query ); if ( is_wp_error( $validation ) ) { error_log( 'PSA: Validation error for "' . $query . '": ' . $validation->get_error_message() ); wp_send_json_success( array( 'status' => 'invalid', 'message' => 'Could not verify this peptide name.', ) ); } if ( empty( $validation['is_valid'] ) ) { wp_send_json_success( array( 'status' => 'invalid', 'message' => $validation['reason'] ?? 'This does not appear to be a recognized peptide.', ) ); } // It's a real peptide — increment rate limit and create placeholder. $ip = psa_get_client_ip(); $rate_key = 'psa_rate_' . md5( $ip ); $count = (int) get_transient( $rate_key ); set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS ); $canonical_name = sanitize_text_field( $validation['canonical_name'] ?? $query ); $placeholder_id = self::create_pending_placeholder( $canonical_name ); if ( is_wp_error( $placeholder_id ) ) { wp_send_json_error( 'Failed to create peptide entry. Please try again.' ); } // Schedule async background generation. error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' ); self::schedule_background_generation( $placeholder_id, $canonical_name ); wp_send_json_success( array( 'status' => 'pending', 'peptide_name' => $canonical_name, ) ); } /** * Find an existing pending peptide post by name. * * Performs a multi-table query to locate pending draft posts by title or alias. * Queries the posts table joined with postmeta for 'psa_source' = 'pending' status, * plus LEFT JOIN on postmeta for aliases. Returns the most recent match. * * @param string $query Search term (peptide name or alias). * @return WP_Post|null Pending post object or null if not found. */ public static function find_pending_peptide( $query ) { global $wpdb; $like = '%' . $wpdb->esc_like( $query ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} src ON p.ID = src.post_id AND src.meta_key = 'psa_source' AND src.meta_value = 'pending' LEFT JOIN {$wpdb->postmeta} alias ON p.ID = alias.post_id AND alias.meta_key = 'psa_aliases' WHERE p.post_type = 'peptide' AND p.post_status = 'draft' AND ( p.post_title LIKE %s OR alias.meta_value LIKE %s ) ORDER BY p.post_date DESC LIMIT 1", $like, $like ) ); return $post_id ? get_post( $post_id ) : null; } /** * Create a placeholder draft post so we can track the pending state. * * @param string $peptide_name The canonical peptide name. * @return int|WP_Error Post ID or error. */ public static function create_pending_placeholder( $peptide_name ) { $post_id = wp_insert_post( array( 'post_type' => 'peptide', 'post_title' => $peptide_name, 'post_content' => '', 'post_status' => 'draft', 'post_author' => psa_get_admin_user_id(), ), true ); if ( ! is_wp_error( $post_id ) ) { update_post_meta( $post_id, 'psa_source', 'pending' ); update_post_meta( $post_id, 'psa_generation_started', time() ); // Allow developers to hook into placeholder creation. do_action( 'psa_peptide_created', $post_id, $peptide_name ); } return $post_id; } /** * Schedule background generation using WP-Cron (non-blocking). * * @param int $post_id The placeholder post ID. * @param string $peptide_name The peptide to research. */ public static function schedule_background_generation( $post_id, $peptide_name ) { $args = array( $post_id, $peptide_name ); if ( ! wp_next_scheduled( 'psa_generate_peptide_background', $args ) ) { wp_schedule_single_event( time(), 'psa_generate_peptide_background', $args ); } spawn_cron(); } /** * Core search logic — queries CPT by title and aliases (published only). * Includes transient caching and batch post/meta priming to avoid N+1 queries. * * @param string $query Search term. * @return array { results: array, total: int, query: string } */ public static function search_peptides( $query ) { global $wpdb; // Check transient cache first. $cache_key = 'psa_search_' . md5( sanitize_text_field( $query ) ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $like = '%' . $wpdb->esc_like( $query ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON p.ID = m.post_id AND m.meta_key = 'psa_aliases' WHERE p.post_type = 'peptide' AND p.post_status = 'publish' AND ( p.post_title LIKE %s OR m.meta_value LIKE %s ) ORDER BY p.post_title ASC LIMIT 20", $like, $like ) ); if ( ! empty( $post_ids ) ) { // Prime the WordPress object cache for all posts and their meta in batch. _prime_post_caches( $post_ids ); update_meta_cache( 'post', $post_ids ); } $results = array(); foreach ( $post_ids as $id ) { $results[] = self::format_peptide( (int) $id ); } $response = array( 'results' => $results, 'total' => count( $results ), 'query' => $query, ); // Cache results for 6 hours. set_transient( $cache_key, $response, 6 * HOUR_IN_SECONDS ); // Allow filtering of search results before return. $response = apply_filters( 'psa_search_results', $response, $query ); return $response; } /** * Format a peptide post for JSON response. * * @param int $post_id The post ID. * @return array */ public static function format_peptide( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return array(); } return array( 'id' => $post->ID, 'title' => $post->post_title, 'url' => get_permalink( $post->ID ), 'excerpt' => wp_trim_words( $post->post_content, 30 ), 'sequence' => get_post_meta( $post->ID, 'psa_sequence', true ), 'molecular_weight' => get_post_meta( $post->ID, 'psa_molecular_weight', true ), 'molecular_formula' => get_post_meta( $post->ID, 'psa_molecular_formula', true ), 'source' => get_post_meta( $post->ID, 'psa_source', true ), ); } }