get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE phone_hash = %s LIMIT 1', Schema::table( 'optouts' ), SiteKey::phoneHash( $phone->canonical() ) ) ); } /** * Put a number on the list. * * @param string $raw Phone number in any form. * @param string $source One of {@see self::SOURCES}. * * @return bool Whether the number was added; false when invalid or already listed. */ public static function add( string $raw, string $source ): bool { $phone = PhoneNumber::parse( $raw ); if ( null === $phone || ! in_array( $source, self::SOURCES, true ) ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table; INSERT IGNORE on the unique hash makes a repeat harmless. $added = (int) $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (phone_hash, phone_masked, source, created_at) VALUES (%s, %s, %s, %s)', Schema::table( 'optouts' ), SiteKey::phoneHash( $phone->canonical() ), $phone->masked(), $source, gmdate( 'Y-m-d H:i:s' ) ) ) > 0; if ( $added ) { self::report( 'optout.added', $phone, $source ); } return $added; } /** * Take a number off the list. * * @param string $raw Phone number in any form. * @param string $source Who asked; one of {@see self::SOURCES}. * * @return bool Whether a row was removed. */ public static function remove( string $raw, string $source = 'admin' ): bool { $phone = PhoneNumber::parse( $raw ); if ( null === $phone || ! in_array( $source, self::SOURCES, true ) ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $removed = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE phone_hash = %s', Schema::table( 'optouts' ), SiteKey::phoneHash( $phone->canonical() ) ) ) > 0; if ( $removed ) { self::report( 'optout.removed', $phone, $source ); } return $removed; } /** * A page of the list, newest first. * * @param int $page Page number, from 1. * @param int $per Rows per page. * @param string $search A phone number to look for; empty for all. * * @return array{rows: list, total: int} */ public static function page( int $page = 1, int $per = 30, string $search = '' ): array { global $wpdb; $table = Schema::table( 'optouts' ); $offset = ( max( 1, $page ) - 1 ) * $per; if ( '' !== trim( $search ) ) { $phone = PhoneNumber::parse( $search ); if ( null === $phone ) { return [ 'rows' => [], 'total' => 0, ]; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin screen. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, phone_masked, source, created_at FROM %i WHERE phone_hash = %s', $table, SiteKey::phoneHash( $phone->canonical() ) ), ARRAY_A ); $total = count( (array) $rows ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin screen. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, phone_masked, source, created_at FROM %i ORDER BY id DESC LIMIT %d OFFSET %d', $table, $per, $offset ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin screen. $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } $list = []; foreach ( (array) $rows as $row ) { $list[] = [ 'id' => (int) $row['id'], 'phone_masked' => (string) $row['phone_masked'], 'source' => (string) $row['source'], 'created_at' => (string) $row['created_at'], ]; } return [ 'rows' => $list, 'total' => $total, ]; } /** * Report a change to the dashboard (EVENTS-v1.md §9). * * @param string $type `optout.added` or `optout.removed`. * @param PhoneNumber $phone The number. * @param string $source Who asked. */ private static function report( string $type, PhoneNumber $phone, string $source ): void { if ( Events::listening() ) { Events::emit( $type, [ 'phone' => $phone->canonical(), 'channel' => 'sms', 'source' => $source, ] ); } } }