post_type, $tracked_types, true ) ) { return; } if ( $post->post_status !== 'publish' ) { return; } if ( $this->is_enrolled( $post_id ) ) { return; } $this->enroll( $post_id ); } public function enroll( int $post_id ): bool { global $wpdb; $result = $wpdb->insert( $wpdb->prefix . 'fpca_posts', [ 'post_id' => $post_id, 'status' => 'unreviewed', 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ], [ '%d', '%s', '%s', '%s' ] ); return $result !== false; } public function is_enrolled( int $post_id ): bool { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE post_id = %d LIMIT 1', $wpdb->prefix . 'fpca_posts', $post_id ) ); } public function enrolled_count(): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'fpca_posts' ) ); } /** * Returns all enrolled posts joined with WP post data — used by the dashboard. */ public function get_dashboard_data( array $args = [] ): array { global $wpdb; $defaults = [ 'orderby' => 'freshness_score', 'order' => 'ASC', ]; $args = wp_parse_args( $args, $defaults ); // word_count is computed in PHP (after stripping HTML), so it's sorted post-fetch. $php_sort_cols = [ 'word_count' ]; $sql_orderby_map = [ 'freshness_score' => 'fs.freshness_score', 'post_modified' => 'p.post_modified', 'status' => 'fs.status', 'post_title' => 'p.post_title', 'assigned_to' => "COALESCE(fa.first_assignee_name, '')", 'gsc_clicks' => 'fs.gsc_clicks', 'gsc_position' => 'fs.gsc_position', 'gsc_ctr' => 'fs.gsc_ctr', ]; $sort_in_php = in_array( $args['orderby'], $php_sort_cols, true ); $orderby_col = $sql_orderby_map[ $args['orderby'] ] ?? 'fs.freshness_score'; $order = strtoupper( $args['order'] ) === 'DESC' ? 'DESC' : 'ASC'; // Push NULLs to the bottom for columns where NULL means "no data". $null_first = ''; if ( $args['orderby'] === 'freshness_score' && $order === 'ASC' ) { $null_first = 'ISNULL(fs.freshness_score) ASC, '; } elseif ( in_array( $args['orderby'], [ 'gsc_clicks', 'gsc_position', 'gsc_ctr' ], true ) ) { $null_first = "ISNULL({$orderby_col}) ASC, "; } $tracked_types = array_values( array_filter( array_map( 'sanitize_key', (array) get_option( 'fpca_tracked_post_types', [ 'post', 'page' ] ) ) ) ); // An empty list would produce `IN ()` (a syntax error) and call prepare() // with no placeholders, which triggers _doing_it_wrong and returns null. if ( empty( $tracked_types ) ) { return []; } $placeholders = implode( ', ', array_fill( 0, count( $tracked_types ), '%s' ) ); // Schema migrations run on admin_init, which fires before any admin page // renders, so every column selected below is guaranteed to exist here. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders -- $placeholders is a generated run of %s, one per element of $tracked_types, and $null_first/$orderby_col/$order are literals picked from the allow-lists above. Every table name and every user-supplied value is bound. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT fs.id, fs.post_id, fs.freshness_score, fs.sub_scores, fs.scored_at, fs.ai_reasoning, fs.status, fs.assigned_to, p.post_title, p.post_modified, p.post_content, p.post_type FROM %i fs JOIN %i p ON fs.post_id = p.ID LEFT JOIN ( SELECT a.post_id, MIN(u.display_name) AS first_assignee_name FROM %i a JOIN %i u ON a.user_id = u.ID GROUP BY a.post_id ) fa ON fa.post_id = fs.post_id WHERE p.post_status = 'publish' AND p.post_type IN ({$placeholders}) ORDER BY {$null_first}{$orderby_col} {$order}", $wpdb->prefix . 'fpca_posts', $wpdb->posts, $wpdb->prefix . 'fpca_assignments', $wpdb->users, ...$tracked_types ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders foreach ( $rows as &$row ) { $row['word_count'] = str_word_count( Freshness_Scorer::get_scoreable_content( (int) $row['post_id'], $row['post_content'] ) ); $row['edit_url'] = get_edit_post_link( (int) $row['post_id'], 'raw' ); $row['view_url'] = get_permalink( (int) $row['post_id'] ); $row['ai_reasons'] = ! empty( $row['ai_reasoning'] ) ? json_decode( $row['ai_reasoning'], true ) : []; // Safely decode sub_scores JSON, defaulting to empty array $decoded_sub_scores = null; if ( ! empty( $row['sub_scores'] ) ) { $decoded_sub_scores = json_decode( $row['sub_scores'], true ); } $row['sub_scores'] = is_array( $decoded_sub_scores ) ? $decoded_sub_scores : []; unset( $row['post_content'], $row['ai_reasoning'] ); } unset( $row ); // Sort columns whose values are only available after PHP processing. if ( $sort_in_php && ! empty( $rows ) ) { $col = $args['orderby']; usort( $rows, function ( $a, $b ) use ( $col, $order ) { $va = $a[ $col ] ?? 0; $vb = $b[ $col ] ?? 0; $cmp = $va <=> $vb; return $order === 'DESC' ? -$cmp : $cmp; } ); } return $rows; } /** * Enrol every published post of the tracked types that isn't already tracked. * Returns count of newly enrolled posts. * * Enrolment is deliberately uncapped on every tier: the only metered * resource is scores per month, and that is enforced server-side at the * proxy. Do not reintroduce a local cap here — one used to stop free sites * at 25 enrolled posts and then report "0 post(s) enrolled." on every * subsequent click. */ public function enroll_existing( array $post_types = [] ): int { if ( empty( $post_types ) ) { $post_types = (array) get_option( 'fpca_tracked_post_types', [ 'post', 'page' ] ); } $posts = get_posts( [ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', ] ); if ( empty( $posts ) ) { return 0; } // One query for everything already tracked, rather than is_enrolled() // per post — this now walks the whole library, not the first 25. global $wpdb; $existing = $wpdb->get_col( $wpdb->prepare( 'SELECT post_id FROM %i', $wpdb->prefix . 'fpca_posts' ) ); $existing = array_flip( array_map( 'intval', (array) $existing ) ); $enrolled = 0; foreach ( $posts as $post_id ) { if ( isset( $existing[ (int) $post_id ] ) ) { continue; } if ( $this->enroll( (int) $post_id ) ) { $enrolled++; } } return $enrolled; } }