$key, 'name' => $name, 'description' => $description, 'icon' => $icon, 'color' => $color, 'metric' => $metric, 'threshold' => $threshold, 'enabled' => true, ); } /** * Cleans the badge list from the settings screen. * * New badges get a key from their name. Keys never change after that, * so members keep badges when a badge is renamed. * * @param array $input Raw rows. * @return array[]|null */ public static function sanitize( array $input ) { $rows = array(); $keys = array(); foreach ( array_slice( array_values( $input ), 0, self::MAX ) as $row ) { if ( ! is_array( $row ) ) { continue; } $name = mb_substr( trim( sanitize_text_field( (string) ( $row['name'] ?? '' ) ) ), 0, 40 ); if ( '' === $name ) { continue; } $key = substr( Slug::make( (string) ( $row['key'] ?? '' ) ), 0, 40 ); if ( '' === $key ) { $key = substr( Slug::make( $name ), 0, 32 ); $key = '' === $key ? 'badge' : $key; } $base = $key; $suffix = 2; while ( isset( $keys[ $key ] ) ) { $key = substr( $base, 0, 36 ) . '-' . $suffix; ++$suffix; } $keys[ $key ] = true; $metric = sanitize_key( (string) ( $row['metric'] ?? 'manual' ) ); $metric = in_array( $metric, self::METRICS, true ) ? $metric : 'manual'; $color = sanitize_hex_color( (string) ( $row['color'] ?? '' ) ); $rows[] = array( 'key' => $key, 'name' => $name, 'description' => mb_substr( trim( sanitize_text_field( (string) ( $row['description'] ?? '' ) ) ), 0, 140 ), 'icon' => substr( sanitize_key( (string) ( $row['icon'] ?? '' ) ), 0, 32 ), 'color' => $color ? strtolower( $color ) : self::COLORS[0], 'metric' => $metric, 'threshold' => 'manual' === $metric ? 0 : max( 1, min( 1000000, (int) ( $row['threshold'] ?? 1 ) ) ), 'enabled' => ! isset( $row['enabled'] ) || rest_sanitize_boolean( $row['enabled'] ), ); } return $rows; } /** * Every badge definition, keyed by key. * * @param bool $enabled_only Leave out switched-off badges. * @return array */ public static function all( $enabled_only = false ) { $badges = Settings::get( 'badges' ); $badges = is_array( $badges ) ? $badges : self::defaults(); $map = array(); foreach ( $badges as $badge ) { if ( ! is_array( $badge ) || empty( $badge['key'] ) || ( $enabled_only && empty( $badge['enabled'] ) ) ) { continue; } $map[ $badge['key'] ] = $badge; } return $map; } /** * The numbers badges look at for one member. * * @param int $user_id Member. * @return array */ public static function metrics( $user_id ) { $counts = Points::counts( $user_id ); return array( 'posts' => $counts['post'], 'comments' => $counts['comment'], 'likes' => $counts['like'], 'visits' => $counts['daily'], 'events' => $counts['event'], 'points' => Points::total( $user_id ), ); } /** * Gives a member every automatic badge they have reached. * * @param int $user_id Member. * @param bool $notify Whether to send a notification for new badges. * @return string[] Keys of new badges. */ public static function check( $user_id, $notify = true ) { $user_id = absint( $user_id ); if ( ! $user_id ) { return array(); } $metrics = self::metrics( $user_id ); $owned = self::keys_of( $user_id ); $new = array(); foreach ( self::all( true ) as $key => $badge ) { if ( 'manual' === $badge['metric'] || in_array( $key, $owned, true ) ) { continue; } if ( ( $metrics[ $badge['metric'] ] ?? 0 ) >= (int) $badge['threshold'] && self::award( $user_id, $key, 0, $notify ) ) { $new[] = $key; } } return $new; } /** * Gives a badge to a member. * * @param int $user_id Member. * @param string $key Badge key. * @param int $awarded_by Team member who gave it, 0 when earned. * @param bool $notify Whether to notify the member. * @return bool Whether the badge is new for the member. */ public static function award( $user_id, $key, $awarded_by = 0, $notify = true ) { global $wpdb; $badges = self::all(); if ( ! isset( $badges[ $key ] ) ) { return false; } $added = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; the unique key keeps one row per badge. $wpdb->prepare( 'INSERT IGNORE INTO %i (user_id, badge, awarded_by, created_at) VALUES (%d, %s, %d, %s)', Tables::name( 'user_badges' ), absint( $user_id ), $key, absint( $awarded_by ), Time::now() ) ); if ( ! $added ) { return false; } /** * Fires after a member gets a badge. * * @since 1.0.0 * * @param int $user_id Member. * @param string $key Badge key. * @param int $awarded_by Team member who gave it, 0 when earned. * @param int $row_id Award ID. * @param bool $notify Whether the member should be notified. */ do_action( 'spacebay_badge_awarded', absint( $user_id ), $key, absint( $awarded_by ), (int) $wpdb->insert_id, $notify ); return true; } /** * Takes a badge away. * * @param int $user_id Member. * @param string $key Badge key. * @return bool */ public static function revoke( $user_id, $key ) { global $wpdb; return (bool) $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'user_badges' ), array( 'user_id' => absint( $user_id ), 'badge' => (string) $key, ) ); } /** * Keys of the badges a member has. * * @param int $user_id Member. * @return string[] */ public static function keys_of( $user_id ) { global $wpdb; return array_map( 'strval', (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh read. $wpdb->prepare( 'SELECT badge FROM %i WHERE user_id = %d', Tables::name( 'user_badges' ), absint( $user_id ) ) ) ); } /** * A member's badges with their definition, newest first. Badges that were * deleted or switched off are left out. * * @param int $user_id Member. * @return array[] */ public static function of_user( $user_id ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh read for one profile. $wpdb->prepare( 'SELECT badge, awarded_by, created_at FROM %i WHERE user_id = %d ORDER BY created_at DESC, id DESC', Tables::name( 'user_badges' ), absint( $user_id ) ), ARRAY_A ); $badges = self::all( true ); $items = array(); foreach ( (array) $rows as $row ) { if ( isset( $badges[ $row['badge'] ] ) ) { $items[] = array_merge( self::format( $badges[ $row['badge'] ] ), array( 'earned' => true, 'given' => (int) $row['awarded_by'] > 0, 'awarded_at' => Time::iso( $row['created_at'] ), ) ); } } return $items; } /** * Number of badges per member for a list of members. * * @param int[] $user_ids Members. * @return array */ public static function count_many( array $user_ids ) { global $wpdb; $user_ids = array_values( array_filter( array_map( 'absint', $user_ids ) ) ); $keys = array_keys( self::all( true ) ); if ( ! $user_ids || ! $keys ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $user_ids ), '%d' ) ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One query per leaderboard page. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT user_id, badge FROM %i WHERE user_id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders are built from %d only. array_merge( array( Tables::name( 'user_badges' ) ), $user_ids ) ), ARRAY_A ); $counts = array(); foreach ( (array) $rows as $row ) { if ( in_array( $row['badge'], $keys, true ) ) { $counts[ (int) $row['user_id'] ] = ( $counts[ (int) $row['user_id'] ] ?? 0 ) + 1; } } return $counts; } /** * Every enabled badge with a member's progress, for their own profile. * * @param int $user_id Member. * @return array[] */ public static function progress( $user_id ) { $metrics = self::metrics( $user_id ); $owned = array(); $items = array(); foreach ( self::of_user( $user_id ) as $badge ) { $owned[ $badge['key'] ] = $badge; } foreach ( self::all( true ) as $key => $badge ) { if ( isset( $owned[ $key ] ) ) { $items[] = $owned[ $key ]; continue; } // Badges the team gives by hand are only listed to members once given. if ( 'manual' === $badge['metric'] ) { continue; } $current = (int) ( $metrics[ $badge['metric'] ] ?? 0 ); $items[] = array_merge( self::format( $badge ), array( 'earned' => false, 'given' => false, 'current' => min( $current, (int) $badge['threshold'] ), ) ); } return $items; } /** * Public fields of a badge. * * @param array $badge Definition. * @return array */ public static function format( array $badge ) { return array( 'key' => (string) $badge['key'], 'name' => (string) $badge['name'], 'description' => (string) $badge['description'], 'icon' => (string) $badge['icon'], 'color' => (string) $badge['color'], 'metric' => (string) $badge['metric'], 'threshold' => (int) $badge['threshold'], ); } /** * Removes every badge of a member. * * @param int $user_id Member. * @return void */ public static function delete_for_user( $user_id ) { global $wpdb; $wpdb->delete( Tables::name( 'user_badges' ), array( 'user_id' => absint( $user_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } }