update( $wpdb->prefix . 'fpca_posts', [ 'status' => $new_status ], [ 'post_id' => $post_id ], [ '%s' ], [ '%d' ] ); if ( $result === false ) { return false; } if ( $new_status === 'done' ) { $this->trigger_rescore( $post_id ); } return true; } /** * AJAX handler for status changes triggered from the admin tables. */ public function ajax_update_status(): void { check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; $new_status = sanitize_key( $_POST['status'] ?? '' ); if ( ! $post_id ) { wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'freshplugins-content-audit' ) ] ); } if ( ! $new_status ) { wp_send_json_error( [ 'message' => __( 'Status is required.', 'freshplugins-content-audit' ) ] ); } $ok = $this->update_status( $post_id, $new_status ); if ( ! $ok ) { wp_send_json_error( [ 'message' => __( 'Invalid or unknown status.', 'freshplugins-content-audit' ) ] ); } if ( $new_status === 'pending_review' ) { $this->notify_assignees( $post_id, 'pending_review' ); } wp_send_json_success( [ 'status' => $new_status ] ); } /** * AJAX handler to dismiss a post from the Suggested for Refresh list. */ public function ajax_dismiss_suggestion(): void { check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; if ( ! $post_id ) { wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'freshplugins-content-audit' ) ] ); } $ok = $this->update_status( $post_id, 'dismissed' ); if ( ! $ok ) { wp_send_json_error( [ 'message' => __( 'Failed to dismiss post.', 'freshplugins-content-audit' ) ] ); } wp_send_json_success( [ 'post_id' => $post_id ] ); } // ------------------------------------------------------------------------- // Auto-flagging // ------------------------------------------------------------------------- /** * Flag a post as needing refresh if it scores below the configured threshold * and is currently unreviewed. * * Hooked to 'fpca_post_scored'. * * @param int $post_id * @param int $score */ public function maybe_flag_post( int $post_id, int $score ): void { $threshold = (int) get_option( 'fpca_flag_threshold', 50 ); if ( $score >= $threshold ) { return; } global $wpdb; $current_status = $wpdb->get_var( $wpdb->prepare( 'SELECT status FROM %i WHERE post_id = %d', $wpdb->prefix . 'fpca_posts', $post_id ) ); if ( $current_status === 'unreviewed' ) { $this->update_status( $post_id, 'needs_refresh' ); } } // ------------------------------------------------------------------------- // Email notifications // ------------------------------------------------------------------------- /** * Send email notifications to all assignees of a post. * * @param int $post_id * @param string $event 'assigned' | 'overdue' | 'pending_review' */ public function notify_assignees( int $post_id, string $event ): void { $assignees = ( new Assignments() )->get_assignees( $post_id ); if ( empty( $assignees ) ) { return; } $post_title = get_the_title( $post_id ); $edit_url = get_edit_post_link( $post_id, 'raw' ); global $wpdb; $score = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT freshness_score FROM %i WHERE post_id = %d', $wpdb->prefix . 'fpca_posts', $post_id ) ); foreach ( $assignees as $assignee ) { $display_name = $assignee['display_name']; $user = get_userdata( $assignee['user_id'] ); if ( ! $user || empty( $user->user_email ) ) { continue; } [ $subject, $body ] = match ( $event ) { 'assigned' => [ /* translators: %s: post title */ sprintf( __( "You've been assigned to refresh: %s", 'freshplugins-content-audit' ), $post_title ), sprintf( /* translators: 1: display name, 2: post title, 3: freshness score, 4: edit URL */ __( "Hi %1\$s,\n\nYou've been assigned to refresh the following post:\n\n%2\$s\nCurrent freshness score: %3\$d\n\nEdit it here: %4\$s\n\nThanks,\nFresh Plugins Content Audit", 'freshplugins-content-audit' ), $display_name, $post_title, $score, $edit_url ), ], 'overdue' => [ /* translators: %s: post title */ sprintf( __( 'Overdue refresh: %s', 'freshplugins-content-audit' ), $post_title ), sprintf( /* translators: 1: display name, 2: post title, 3: freshness score, 4: edit URL */ __( "Hi %1\$s,\n\nThis post is overdue for a refresh:\n\n%2\$s\nCurrent freshness score: %3\$d\n\nEdit it here: %4\$s\n\nThanks,\nFresh Plugins Content Audit", 'freshplugins-content-audit' ), $display_name, $post_title, $score, $edit_url ), ], 'pending_review' => [ /* translators: %s: post title */ sprintf( __( 'Ready for review: %s', 'freshplugins-content-audit' ), $post_title ), sprintf( /* translators: 1: display name, 2: post title, 3: edit URL */ __( "Hi %1\$s,\n\nA post has been marked as Pending Review:\n\n%2\$s\n\nReview it here: %3\$s\n\nThanks,\nFresh Plugins Content Audit", 'freshplugins-content-audit' ), $display_name, $post_title, $edit_url ), ], default => [ null, null ], }; if ( null === $subject ) { continue; } wp_mail( $user->user_email, $subject, $body ); } } // ------------------------------------------------------------------------- // Overdue checker // ------------------------------------------------------------------------- /** * Find posts that are overdue for a refresh and notify their assignees. * * Called daily via WP-Cron. */ public function check_overdue(): void { if ( ! $this->schema_is_current() ) { return; } global $wpdb; $overdue_days = (int) get_option( 'fpca_overdue_days', 7 ); $fp = $wpdb->prefix . 'fpca_posts'; $fa = $wpdb->prefix . 'fpca_assignments'; $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT fp.post_id FROM %i fp INNER JOIN %i fa ON fp.post_id = fa.post_id WHERE fp.status IN ('in_progress', 'needs_refresh') AND fp.updated_at < NOW() - INTERVAL %d DAY", $fp, $fa, $overdue_days ) ); foreach ( $post_ids as $post_id ) { $this->notify_assignees( (int) $post_id, 'overdue' ); } } // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- /** * Callback for 'fpca_post_assigned' action. * * @param int $post_id */ public function on_post_assigned( int $post_id ): void { $this->notify_assignees( $post_id, 'assigned' ); } /** * Callback for 'fpca_rescore_post' cron action. * * @param int $post_id */ public function do_rescore( int $post_id ): void { if ( ! $this->schema_is_current() ) { return; } // No user is present to confirm the data disclosure when this cron event // fires, so it must not transmit content until acknowledgement happened // at some point before now. if ( ! get_option( 'fpca_scoring_ack', 0 ) ) { return; } ( new Freshness_Scorer() )->score( $post_id ); } /** * Queue a rescore for a post 60 seconds from now. * * @param int $post_id */ private function trigger_rescore( int $post_id ): void { wp_schedule_single_event( time() + 60, 'fpca_rescore_post', [ $post_id ] ); } // ------------------------------------------------------------------------- // Weekly auto-rescore cron // ------------------------------------------------------------------------- /** * Rescore all enrolled posts whose freshness_score is older than 7 days. * Runs weekly via WP-Cron on every tier — the monthly scoring allowance is * enforced at the proxy, so the run simply stops when the proxy says the * allowance is spent (see the rate-limit check in the loop below). */ public function do_weekly_rescore(): void { if ( ! $this->schema_is_current() ) { return; } if ( ! get_option( 'fpca_auto_rescore', false ) ) { return; } // Nobody is present to confirm the data disclosure for a weekly cron run, // so it must not transmit content until acknowledgement happened at some // point before now. if ( ! get_option( 'fpca_scoring_ack', 0 ) ) { return; } global $wpdb; $post_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT post_id FROM %i WHERE scored_at IS NULL OR scored_at < DATE_SUB( NOW(), INTERVAL 7 DAY )', $wpdb->prefix . 'fpca_posts' ) ); if ( empty( $post_ids ) ) { return; } $scorer = new Freshness_Scorer(); $batches = array_chunk( $post_ids, 5 ); foreach ( $batches as $i => $batch ) { if ( $i > 0 ) { sleep( 2 ); // Avoid hammering the proxy between batches. } foreach ( $batch as $post_id ) { $result = $scorer->score( (int) $post_id ); // Keep the dashboard's usage figures current: this run may be // the only scoring a site does all month. License::record_usage( $result['usage'] ?? null ); if ( self::is_rate_limited( $result ) ) { // The allowance is spent. Every further call this run would // be refused with the same 429, so stop the whole run rather // than work through the remaining posts and batches. return; } } } } /** * Whether a Freshness_Scorer result means the monthly allowance is spent. * * Freshness_Scorer::score() only attaches a `usage` array to an * unsuccessful result on the proxy's HTTP 429 branch, and always fills * `remaining` there (defaulting to 0). A successful score also carries * `usage`, hence the success check: both conditions together identify a * rate-limit refusal and nothing else. * * @param array $result A Freshness_Scorer::score() return value. */ private static function is_rate_limited( array $result ): bool { return empty( $result['success'] ) && isset( $result['usage']['remaining'] ) && (int) $result['usage']['remaining'] <= 0; } }