wpdb = $wpdb; $this->tables = $tables; } /** * Record a sanitized click event. * * @param array $event Sanitized event payload. */ public function record( array $event ): void { $createdAt = self::stringField( $event, 'created_at', gmdate( 'c' ) ); $date = substr( $createdAt, 0, 10 ); $provider = self::stringField( $event, 'provider', 'unknown' ); $buttonKey = self::stringField( $event, 'button_key', 'unknown' ); $variant = self::stringField( $event, 'variant', '' ); $productId = self::intField( $event, 'product_id' ); $postId = self::intField( $event, 'post_id' ); $metadata = isset( $event['metadata'] ) && is_array( $event['metadata'] ) ? $event['metadata'] : array(); $this->wpdb->insert( $this->tables->resolve( 'affilun_click_events' ), array( 'provider' => $provider, 'button_key' => $buttonKey, 'target_url_hash' => self::stringField( $event, 'target_url_hash', '' ), 'product_id' => $productId, 'post_id' => $postId, 'variant' => $variant, 'user_agent_hash' => self::stringField( $metadata, 'user_agent_hash', '' ), 'referer_host' => self::stringField( $metadata, 'referer_host', '' ), 'created_at' => gmdate( 'Y-m-d H:i:s', strtotime( $createdAt ) ?: time() ), ), array( '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s' ) ); $this->upsertRollup( $date, $provider, $buttonKey, $variant, $productId, $postId ); } /** * Return recent click events. * * @param int $limit Maximum rows. * * @return list> */ public function recentEvents( int $limit = 500 ): array { $limit = max( 1, min( 5000, $limit ) ); $table = $this->tables->resolve( 'affilun_click_events' ); $sql = 'SELECT created_at, provider, button_key, product_id, post_id, variant, target_url_hash ' . "FROM {$table} ORDER BY created_at DESC, id DESC LIMIT %d"; $rows = $this->wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL contains only resolver-produced table name and placeholder; value bound immediately here. $this->wpdb->prepare( $sql, $limit ), ARRAY_A ); if ( ! is_array( $rows ) ) { return array(); } $events = array(); foreach ( $rows as $row ) { if ( is_array( $row ) ) { $events[] = $row; } } return $events; } /** * Count recent click events matching a client bucket. * * @param string $userAgentHash User-agent hash. * @param string $refererHost Referer host. * @param DateTimeImmutable $since Lower bound. */ public function countRecentMatching( string $userAgentHash, string $refererHost, DateTimeImmutable $since ): int { $table = $this->tables->resolve( 'affilun_click_events' ); $since = $since->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); $sql = "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND user_agent_hash = %s AND referer_host = %s"; $count = $this->wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQL contains resolver-produced table name and placeholders; values bound immediately here. $this->wpdb->prepare( $sql, $since, $userAgentHash, $refererHost ) ); return is_numeric( $count ) ? max( 0, (int) $count ) : 0; } /** * Delete click events and rollups older than cutoff. * * @param DateTimeImmutable $cutoff Cutoff instant. */ public function deleteOlderThan( DateTimeImmutable $cutoff ): int { $cutoff = $cutoff->setTimezone( new DateTimeZone( 'UTC' ) ); $eventTable = $this->tables->resolve( 'affilun_click_events' ); $rollupTable = $this->tables->resolve( 'affilun_click_rollups' ); $cutoffDate = $cutoff->format( 'Y-m-d' ); $cutoffDateTime = $cutoff->format( 'Y-m-d H:i:s' ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- SQL is prepared inline in this query call. $this->wpdb->query( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQL contains resolver-produced table name and one placeholder. "DELETE FROM {$eventTable} WHERE created_at < %s", $cutoffDateTime ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared $deleted = isset( $this->wpdb->rows_affected ) ? max( 0, (int) $this->wpdb->rows_affected ) : 0; // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- SQL is prepared inline in this query call. $this->wpdb->query( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQL contains resolver-produced table name and one placeholder. "DELETE FROM {$rollupTable} WHERE rollup_date < %s", $cutoffDate ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared return $deleted; } /** * Upsert the daily rollup row. * * @param string $date Rollup date. * @param string $provider Provider token. * @param string $buttonKey Button key. * @param string $variant Variant token. * @param int|null $productId Product id. * @param int|null $postId Post id. */ private function upsertRollup( string $date, string $provider, string $buttonKey, string $variant, ?int $productId, ?int $postId ): void { $table = $this->tables->resolve( 'affilun_click_rollups' ); $sql = "INSERT INTO {$table} (rollup_date, provider, button_key, variant, product_id, post_id, clicks, updated_at) " . 'VALUES (%s, %s, %s, %s, %d, %d, 1, %s) ' . 'ON DUPLICATE KEY UPDATE clicks = clicks + 1, updated_at = VALUES(updated_at)'; // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- SQL is prepared inline in this query call. $this->wpdb->query( $this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL contains only a resolver-produced table name and placeholders; values are bound immediately here. $sql, $date, $provider, $buttonKey, $variant, $productId ?? 0, $postId ?? 0, gmdate( 'Y-m-d H:i:s' ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared } /** * Read a string field. * * @param array $event Event or metadata. * @param string $field Field name. * @param string $fallback Fallback value. */ private static function stringField( array $event, string $field, string $fallback ): string { $value = $event[ $field ] ?? null; return is_string( $value ) && trim( $value ) !== '' ? trim( $value ) : $fallback; } /** * Read a positive integer field. * * @param array $event Event. * @param string $field Field name. */ private static function intField( array $event, string $field ): ?int { $value = $event[ $field ] ?? null; return is_int( $value ) && $value > 0 ? $value : null; } }