*/ private const CHANNEL_LABELS = [ 'notify' => 'ارسال پیامک و ایمیل', 'otp' => 'ورود پیامکی', 'queue' => 'صف کارها', 'stock' => 'موجودی کالا', 'cart' => 'سبد رهاشده', ]; /** * Fetch a page of log rows. * * @param array{channel?:string, level?:int|null, search?:string, from?:string, to?:string} $filters Active filters. * @param int $page 1-based page number. * * @return list> */ public static function page( array $filters, int $page = 1 ): array { global $wpdb; $table = Schema::table( 'logs' ); [ $where, $params ] = self::buildWhere( $filters ); $offset = max( 0, ( max( 1, $page ) - 1 ) * self::PER_PAGE ); $params[] = self::PER_PAGE; $params[] = $offset; // `$where` is assembled in buildWhere() from literal fragments only; every // value in it is a placeholder bound through $params. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i {$where} ORDER BY id DESC LIMIT %d OFFSET %d", array_merge( [ $table ], $params ) ), ARRAY_A ); return is_array( $rows ) ? $rows : []; } /** * How many rows match the filters. * * @param array{channel?:string, level?:int|null, search?:string, from?:string, to?:string} $filters Active filters. */ public static function count( array $filters ): int { global $wpdb; $table = Schema::table( 'logs' ); [ $where, $params ] = self::buildWhere( $filters ); // See page() for why `$where` is safe to interpolate. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i {$where}", array_merge( [ $table ], $params ) ) ); } /** * Total rows in the table, regardless of filters. */ public static function total(): int { global $wpdb; $table = Schema::table( 'logs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } /** * Approximate disk footprint of the log table, in bytes. * * Read from `information_schema`, which is an estimate rather than exact — * good enough to answer "is this getting out of hand?", which is the only * question being asked, and free compared to counting rows. */ public static function sizeOnDisk(): int { global $wpdb; $table = Schema::table( 'logs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT data_length + index_length FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = %s', $table ) ); } /** * Channels that actually appear in the table, with labels. * * Discovered rather than hard-coded, so a module added later shows up in the * filter on its own. * * @return array */ public static function channels(): array { global $wpdb; $table = Schema::table( 'logs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $found = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT channel FROM %i ORDER BY channel', $table ) ); $channels = []; foreach ( (array) $found as $channel ) { $channel = (string) $channel; if ( '' === $channel ) { continue; } $channels[ $channel ] = self::CHANNEL_LABELS[ $channel ] ?? $channel; } return $channels; } /** * Row counts per level, for the summary strip. * * @return array */ public static function countsByLevel(): array { global $wpdb; $table = Schema::table( 'logs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT level, COUNT(*) AS total FROM %i GROUP BY level', $table ), ARRAY_A ); $counts = []; foreach ( (array) $rows as $row ) { $counts[ (int) $row['level'] ] = (int) $row['total']; } return $counts; } /** * Build the WHERE clause and its bound parameters. * * @param array{channel?:string, level?:int|null, search?:string, from?:string, to?:string} $filters Active filters. * * @return array{0:string, 1:list} */ private static function buildWhere( array $filters ): array { $clauses = []; $params = []; if ( ! empty( $filters['channel'] ) ) { $clauses[] = 'channel = %s'; $params[] = (string) $filters['channel']; } if ( isset( $filters['level'] ) && null !== $filters['level'] && '' !== $filters['level'] ) { $clauses[] = 'level <= %d'; $params[] = (int) $filters['level']; } if ( ! empty( $filters['from'] ) ) { $clauses[] = 'created_at >= %s'; $params[] = (string) $filters['from'] . ' 00:00:00'; } if ( ! empty( $filters['to'] ) ) { $clauses[] = 'created_at <= %s'; $params[] = (string) $filters['to'] . ' 23:59:59'; } if ( ! empty( $filters['search'] ) ) { global $wpdb; // Searched against message and context both, because the useful string // — an order number, a provider error — is as likely to be in one as // the other. Neither column is indexed for this, which is why the // search box is not offered as a default view. $clauses[] = '(message LIKE %s OR context LIKE %s)'; $like = '%' . $wpdb->esc_like( (string) $filters['search'] ) . '%'; $params[] = $like; $params[] = $like; } return [ [] === $clauses ? '' : 'WHERE ' . implode( ' AND ', $clauses ), $params, ]; } }