prefix . 'chargeforms_form_starts'; } private static function record( $form_id, $event_type ) { global $wpdb; $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $key = 'chargeforms_' . $event_type . '_' . md5( $form_id . '|' . $ip . '|' . gmdate( 'Y-m-d' ) ); if ( false !== get_transient( $key ) ) { return; } $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. self::table(), [ 'form_id' => (int) $form_id, 'event_type' => $event_type, // GMT, explicit (roadmap issue #27) — count_for_form()/ // count_total()'s date_from/date_to filters already assume // GMT (they don't use gmdate() but their caller, // Reports_Page::resolve_date_preset(), now produces GMT // boundaries — see that method's own doc comment). 'created_at' => current_time( 'mysql', true ), ], [ '%d', '%s', '%s' ] ); set_transient( $key, 1, DAY_IN_SECONDS ); } public static function record_start( $form_id ) { self::record( $form_id, 'start' ); } public static function record_view( $form_id ) { self::record( $form_id, 'view' ); } /** * A honeypot catch — real bot-blocked attempts, not entries (the * submission handler rejects these before an Entry is ever created, * see frontend/class-submission-handler.php's honeypot check). Backs * Reports' "Spam Submissions" stat, which used to be hardcoded to 0 * with a fake "0%" change badge; that number is honestly always 0 if * "spam" means "spam rows in the entries table" (there are none, by * design — ChargeForms never saves what the honeypot catches), but a * permanently-zero stat with a fake trend is exactly the kind of fake * UI the project's own rules rule out. This tracks the real signal * instead: how many submissions actually got blocked. */ public static function record_spam_blocked( $form_id ) { self::record( $form_id, 'spam_blocked' ); } public static function count_for_form( $form_id, $event_type = 'start', $date_from = '', $date_to = '' ) { global $wpdb; $table = self::table(); $from = $date_from ? $date_from . ' 00:00:00' : ''; $to = $date_to ? $date_to . ' 23:59:59' : ''; // Every branch below is a genuine, fully-inline literal at the // prepare() call site — the live Plugin Check scanner's // PreparedSQL.NotPrepared check flags any variable indirection // here, even a safely-built one, so no query text (or fragment of // it) is ever held in a variable before reaching prepare(). if ( $from && $to ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE form_id = %d AND event_type = %s AND created_at >= %s AND created_at <= %s', $table, $form_id, $event_type, $from, $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $from ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE form_id = %d AND event_type = %s AND created_at >= %s', $table, $form_id, $event_type, $from ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $to ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE form_id = %d AND event_type = %s AND created_at <= %s', $table, $form_id, $event_type, $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE form_id = %d AND event_type = %s', $table, $form_id, $event_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } /** * Same event counts as count_for_form(), for MULTIPLE forms in one * GROUP BY query instead of one call per form (roadmap issue #36 — * see Entry::count_by_form()'s identical doc comment for the "Top * Forms" widget this backs). A form with zero matching events simply * has no key in the returned map. * * @return array form_id => count */ public static function count_by_form( array $form_ids, $event_type = 'start', $date_from = '', $date_to = '' ) { global $wpdb; $form_ids = array_values( array_unique( array_map( 'intval', $form_ids ) ) ); if ( empty( $form_ids ) ) { return []; } $table = self::table(); $placeholders = implode( ',', array_fill( 0, count( $form_ids ), '%d' ) ); $from = $date_from ? $date_from . ' 00:00:00' : ''; $to = $date_to ? $date_to . ' 23:59:59' : ''; // Every branch below is a genuine, fully-inline literal at the // prepare() call site (the {$placeholders} fragment is the one // exception the live Plugin Check scanner accepts — a single // interpolated variable inside an otherwise-literal string, not a // concatenation of separate fragment variables). if ( $from && $to ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT form_id, COUNT(*) AS total FROM %i WHERE form_id IN ({$placeholders}) AND event_type = %s AND created_at >= %s AND created_at <= %s GROUP BY form_id", array_merge( [ $table ], $form_ids, [ $event_type, $from, $to ] ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. } elseif ( $from ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT form_id, COUNT(*) AS total FROM %i WHERE form_id IN ({$placeholders}) AND event_type = %s AND created_at >= %s GROUP BY form_id", array_merge( [ $table ], $form_ids, [ $event_type, $from ] ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. } elseif ( $to ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT form_id, COUNT(*) AS total FROM %i WHERE form_id IN ({$placeholders}) AND event_type = %s AND created_at <= %s GROUP BY form_id", array_merge( [ $table ], $form_ids, [ $event_type, $to ] ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT form_id, COUNT(*) AS total FROM %i WHERE form_id IN ({$placeholders}) AND event_type = %s GROUP BY form_id", array_merge( [ $table ], $form_ids, [ $event_type ] ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. } $counts = []; foreach ( $rows ?: [] as $row ) { $counts[ (int) $row['form_id'] ] = (int) $row['total']; } return $counts; } /** Total events across every form (or one, if $form_id given) in a date range — the denominator for an overview-level completion rate. */ public static function count_total( $form_id = 0, $date_from = '', $date_to = '', $event_type = 'start' ) { global $wpdb; $table = self::table(); $from = $date_from ? $date_from . ' 00:00:00' : ''; $to = $date_to ? $date_to . ' 23:59:59' : ''; // Every branch below is a genuine, fully-inline literal at the // prepare() call site — see count_for_form()'s identical note above. if ( $form_id && $from && $to ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND form_id = %d AND created_at >= %s AND created_at <= %s', $table, $event_type, $form_id, $from, $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $form_id && $from ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND form_id = %d AND created_at >= %s', $table, $event_type, $form_id, $from ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $form_id && $to ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND form_id = %d AND created_at <= %s', $table, $event_type, $form_id, $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $form_id ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND form_id = %d', $table, $event_type, $form_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $from && $to ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND created_at >= %s AND created_at <= %s', $table, $event_type, $from, $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $from ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND created_at >= %s', $table, $event_type, $from ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } if ( $to ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s AND created_at <= %s', $table, $event_type, $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = %s', $table, $event_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } /** * Same current-vs-prior-equal-length-period comparison as * Entry::percent_change() (see that method's doc comment), just * counting events here instead of entries — kept as a genuinely * separate method rather than a shared helper since the two query * different tables with different column sets. */ public static function percent_change( $form_id, $date_from, $date_to, $event_type = 'start' ) { if ( ! $date_from || ! $date_to ) { return null; } $days = max( 1, ( strtotime( $date_to ) - strtotime( $date_from ) ) / DAY_IN_SECONDS + 1 ); $prior_to = gmdate( 'Y-m-d', strtotime( $date_from . ' -1 day' ) ); $prior_from = gmdate( 'Y-m-d', strtotime( $prior_to . ' -' . ( $days - 1 ) . ' days' ) ); $current = self::count_total( $form_id, $date_from, $date_to, $event_type ); $prior = self::count_total( $form_id, $prior_from, $prior_to, $event_type ); if ( 0 === $prior ) { return null; } return round( ( ( $current - $prior ) / $prior ) * 100 ); } }