query( $wpdb->prepare( 'INSERT INTO %i (faq_id, event_type, event_day, hits) VALUES (%d, %s, %s, 1) ON DUPLICATE KEY UPDATE hits = hits + 1', $table, $faq_id, $type, $day ) ); // New event data invalidates the cached totals/trend aggregates. wp_cache_delete( 'last_changed', Database::CACHE_GROUP ); } /** * Guard: is this FAQ id valid & published? * * @param int $faq_id ID. * @return bool */ private static function valid( $faq_id ) { return $faq_id > 0 && PostType::POST_TYPE === get_post_type( $faq_id ) && 'publish' === get_post_status( $faq_id ); } /** * Record a view (once per visitor per FAQ per 6h). * * @param int $faq_id FAQ ID. * @param string $token Client fingerprint (hashed). * @return int New view count, or -1 on invalid. */ public static function record_view( $faq_id, $token = '' ) { $faq_id = absint( $faq_id ); if ( ! self::valid( $faq_id ) ) { return -1; } $key = 'afaq_v_' . md5( $faq_id . '|' . $token . '|' . self::client_ip() ); if ( get_transient( $key ) ) { return (int) get_post_meta( $faq_id, self::META_VIEWS, true ); } set_transient( $key, 1, 6 * HOUR_IN_SECONDS ); $count = (int) get_post_meta( $faq_id, self::META_VIEWS, true ) + 1; update_post_meta( $faq_id, self::META_VIEWS, $count ); self::bump_daily( $faq_id, 'view' ); return $count; } /** * Record a helpful/unhelpful vote (once per visitor per FAQ per 30 days). * * @param int $faq_id FAQ ID. * @param string $dir up|down. * @param string $token Client fingerprint. * @return array|null [ up, down ] or null on invalid/duplicate. */ public static function record_vote( $faq_id, $dir, $token = '' ) { $faq_id = absint( $faq_id ); $dir = ( 'down' === $dir ) ? 'down' : 'up'; if ( ! self::valid( $faq_id ) ) { return null; } $key = 'afaq_vote_' . md5( $faq_id . '|' . $token . '|' . self::client_ip() ); if ( get_transient( $key ) ) { return null; } set_transient( $key, $dir, 30 * DAY_IN_SECONDS ); $meta = ( 'up' === $dir ) ? self::META_UP : self::META_DOWN; $count = (int) get_post_meta( $faq_id, $meta, true ) + 1; update_post_meta( $faq_id, $meta, $count ); self::bump_daily( $faq_id, $dir ); return array( 'up' => (int) get_post_meta( $faq_id, self::META_UP, true ), 'down' => (int) get_post_meta( $faq_id, self::META_DOWN, true ), ); } /** * Hashed client IP for rate limiting (never stored raw). * * @return string */ private static function client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return md5( $ip ); } /* ------------------------------------------------------------------ */ /* Reporting */ /* ------------------------------------------------------------------ */ /** * Global totals. * * @return array */ public static function totals() { $last_changed = wp_cache_get_last_changed( Database::CACHE_GROUP ); $cache_key = 'totals:' . $last_changed; $cached = wp_cache_get( $cache_key, Database::CACHE_GROUP ); if ( false !== $cached ) { return $cached; } global $wpdb; $table = Database::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- reading the plugin's own analytics table has no WordPress API alternative; results are object-cached above. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT event_type, SUM(hits) AS total FROM %i GROUP BY event_type', $table ), ARRAY_A ); $out = array( 'view' => 0, 'up' => 0, 'down' => 0, ); foreach ( (array) $rows as $row ) { if ( isset( $out[ $row['event_type'] ] ) ) { $out[ $row['event_type'] ] = (int) $row['total']; } } wp_cache_set( $cache_key, $out, Database::CACHE_GROUP, HOUR_IN_SECONDS ); return $out; } /** * Daily trend series for the last N days. * * @param string $type view|up|down. * @param int $days Days back. * @return array day => hits. */ public static function trend( $type = 'view', $days = 30 ) { $last_changed = wp_cache_get_last_changed( Database::CACHE_GROUP ); $cache_key = 'trend:' . $type . ':' . (int) $days . ':' . $last_changed; $cached = wp_cache_get( $cache_key, Database::CACHE_GROUP ); if ( false !== $cached ) { return $cached; } global $wpdb; $table = Database::table(); $since = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- reading the plugin's own analytics table has no WordPress API alternative; results are object-cached above. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT event_day, SUM(hits) AS hits FROM %i WHERE event_type = %s AND event_day >= %s GROUP BY event_day ORDER BY event_day ASC', $table, $type, $since ), ARRAY_A ); $series = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $series[ gmdate( 'Y-m-d', time() - ( $i * DAY_IN_SECONDS ) ) ] = 0; } foreach ( (array) $rows as $row ) { $series[ $row['event_day'] ] = (int) $row['hits']; } wp_cache_set( $cache_key, $series, Database::CACHE_GROUP, HOUR_IN_SECONDS ); return $series; } /** * Top FAQs by a meta measure. * * @param string $meta Meta key. * @param int $limit Rows. * @return array[] [ id, title, value ]. */ public static function top( $meta = self::META_VIEWS, $limit = 8 ) { $q = new \WP_Query( array( 'post_type' => PostType::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => (int) $limit, 'orderby' => 'meta_value_num', 'meta_key' => $meta, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'order' => 'DESC', 'no_found_rows' => true, ) ); $out = array(); foreach ( $q->posts as $post ) { $val = (int) get_post_meta( $post->ID, $meta, true ); if ( $val <= 0 ) { continue; } $out[] = array( 'id' => $post->ID, 'title' => get_the_title( $post ), 'value' => $val, ); } wp_reset_postdata(); return $out; } }