launch( self::BATCH ); Services::waitlist()->sweep(); $grants = \RoosterLMS\Enrolment\LaunchGrants::run_due(); $out['more'] = $out['more'] || $grants['more']; if ( $out['more'] && function_exists( 'as_enqueue_async_action' ) ) { as_enqueue_async_action( self::LAUNCH_HOOK, array(), Runs::GROUP, false ); } } public function join_user( int $user_id, int $course_id, string $lang = '' ): bool { if ( $user_id <= 0 ) { DomainError::raise( ErrorCode::FORBIDDEN ); } $this->require_open( $course_id ); $lang = Language::normalize( '' !== $lang ? $lang : Switcher::current() ); Transaction::run( static function () use ( $user_id, $course_id, $lang ): void { $now = Transaction::now(); Db::query( 'INSERT IGNORE INTO %i (course_id, user_id, email, lang, token, confirmed_at, created_at, notified_at) VALUES (%d, %d, NULL, %s, NULL, %s, %s, NULL)', Db::table( 'course_waitlist' ), $course_id, $user_id, $lang, $now, $now ); } ); return true; } public function leave_user( int $user_id, int $course_id ): int { return $user_id > 0 ? Db::query( 'DELETE FROM %i WHERE course_id = %d AND user_id = %d', Db::table( 'course_waitlist' ), $course_id, $user_id ) : 0; } public function join_guest( string $email, int $course_id, string $lang = '' ): bool { if ( true !== CommunitySettings::get( 'waitlist_guests' ) ) { DomainError::raise( ErrorCode::FORBIDDEN, array( 'reason' => 'guests_off' ) ); } $email = strtolower( trim( sanitize_email( $email ) ) ); if ( '' === $email || ! is_email( $email ) || strlen( $email ) > 190 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'email' ) ); } $this->require_open( $course_id ); if ( ! FeedRate::count( 'waitlist', 10 ) || ! self::address_rate( $email ) ) { return false; } $lang = Language::normalize( '' !== $lang ? $lang : Switcher::current() ); $token = (string) Transaction::run( static function () use ( $email, $course_id, $lang ): string { $table = Db::table( 'course_waitlist' ); $row = Db::row( 'SELECT id, confirmed_at FROM %i WHERE course_id = %d AND email = %s FOR UPDATE', $table, $course_id, $email ); if ( null !== $row && null !== $row['confirmed_at'] ) { return ''; } $token = bin2hex( random_bytes( 16 ) ); $now = Transaction::now(); if ( null === $row ) { Db::query( 'INSERT INTO %i (course_id, user_id, email, lang, token, confirmed_at, created_at, notified_at) VALUES (%d, NULL, %s, %s, %s, NULL, %s, NULL)', $table, $course_id, $email, $lang, $token, $now ); } else { Db::query( 'UPDATE %i SET token = %s, lang = %s, created_at = %s WHERE id = %d', $table, $token, $lang, $now, (int) $row['id'] ); } return $token; } ); if ( '' !== $token ) { $this->send_confirmation( $email, $course_id, $token, $lang ); } return true; } public static function address_rate( string $email ): bool { $key = 'rooster_lms_wl_' . substr( wp_hash( 'waitlist_email|' . $email ), 0, 24 ); $now = Clock::timestamp(); $slot = get_transient( $key ); $slot = is_array( $slot ) && (int) ( $slot[1] ?? 0 ) > $now ? $slot : array( 0, $now + HOUR_IN_SECONDS ); if ( (int) $slot[0] >= self::ADDRESS_LIMIT ) { return false; } set_transient( $key, array( (int) $slot[0] + 1, (int) $slot[1] ), max( 1, (int) $slot[1] - $now ) ); return true; } public static function url( string $token ): string { return add_query_arg( self::ARG, rawurlencode( $token ), home_url( '/' ) ); } public static function secret( string $token ): string { return substr( hash_hmac( 'sha256', 'rooster-lms-waitlist|' . $token, wp_salt( 'nonce' ) ), 0, 32 ); } public static function nonce_action( string $token ): string { return 'rooster_lms_waitlist_' . $token; } public static function route(): void { $raw = \RoosterLMS\UI\TableRequest::get( self::ARG ); if ( ! is_string( $raw ) || '' === $raw ) { return; } $token = substr( preg_replace( '/[^a-f0-9]/', '', strtolower( $raw ) ) ?? '', 0, 32 ); $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; $post = 'POST' === $method && \RoosterLMS\Admin\Request::posted_nonce_valid( RsvpPage::NONCE_FIELD, self::nonce_action( $token ) ); $secret = \RoosterLMS\Admin\Request::posted_key( 'rl_secret' ); $action = \RoosterLMS\Admin\Request::posted_key( 'rl_do' ); $page = Services::waitlist()->page( $token, $post, $secret, $action ); status_header( 200 ); foreach ( RsvpPage::HEADERS as $name => $value ) { header( $name . ': ' . $value ); } header( 'Content-Type: text/html; charset=utf-8' ); RsvpPage::output( $page ); exit; } public function page( string $token, bool $post, string $secret, string $action = 'confirm' ): string { $row = $this->by_token( $token ); if ( null === $row || ( null === $row['confirmed_at'] && strtotime( (string) $row['created_at'] . ' UTC' ) < Clock::timestamp() - self::CONFIRM_HOURS * HOUR_IN_SECONDS ) ) { return '
' . esc_html__( 'The link may have expired or be incomplete. Ask again from the course\'s page.', 'rooster-lms' ) . '
'; } return (string) Switcher::with( (string) $row['lang'], function () use ( $row, $token, $post, $secret, $action ): string { $title = self::course_title( (int) $row['course_id'] ); $head = '' . esc_html__( 'You are off the waiting list. We will not e-mail you about this course.', 'rooster-lms' ) . '
'; } if ( $ok && null === $row['confirmed_at'] ) { $this->confirm( $token ); $row['confirmed_at'] = Clock::now(); } if ( null === $row['confirmed_at'] ) { return $head . '' . esc_html__( 'Confirm your e-mail address to get one e-mail when this course opens.', 'rooster-lms' ) . '
' . $button( 'confirm', __( 'Confirm', 'rooster-lms' ) ); } return $head . '' . esc_html__( 'You are on the waiting list. We will e-mail you once when this course opens.', 'rooster-lms' ) . '
' . '' . esc_html__( 'Changed your mind?', 'rooster-lms' ) . '
' . $button( 'leave', __( 'Leave the waiting list', 'rooster-lms' ) ); } ); } public function confirm( string $token ): int { return (int) Transaction::run( function () use ( $token ): int { $table = Db::table( 'course_waitlist' ); $row = Db::row( 'SELECT id, course_id, email, confirmed_at FROM %i WHERE token = %s FOR UPDATE', $table, $token ); if ( null === $row || '' === $token ) { return 0; } if ( null === $row['confirmed_at'] ) { $user = get_user_by( 'email', (string) $row['email'] ); $uid = $user instanceof \WP_User ? (int) $user->ID : 0; if ( $uid > 0 && null !== Db::value( 'SELECT 1 FROM %i WHERE course_id = %d AND user_id = %d', $table, (int) $row['course_id'], $uid ) ) { $uid = 0; } if ( $uid > 0 ) { Db::query( 'UPDATE %i SET confirmed_at = %s, user_id = %d WHERE id = %d', $table, Transaction::now(), $uid, (int) $row['id'] ); } else { Db::query( 'UPDATE %i SET confirmed_at = %s WHERE id = %d', $table, Transaction::now(), (int) $row['id'] ); } } return (int) $row['course_id']; } ); } public function leave_token( string $token ): int { return '' === $token ? 0 : Db::query( 'DELETE FROM %i WHERE token = %s', Db::table( 'course_waitlist' ), $token ); } public function by_token( string $token ): ?array { return 32 === strlen( $token ) ? Db::row( 'SELECT * FROM %i WHERE token = %s', Db::table( 'course_waitlist' ), $token ) : null; } public function has( int $user_id, int $course_id ): bool { return $user_id > 0 && null !== Db::value( 'SELECT 1 FROM %i WHERE course_id = %d AND user_id = %d AND confirmed_at IS NOT NULL', Db::table( 'course_waitlist' ), $course_id, $user_id ); } public function counts( int $course_id ): array { $row = Db::row( 'SELECT SUM( confirmed_at IS NOT NULL ) AS c, SUM( confirmed_at IS NULL ) AS p, SUM( notified_at IS NOT NULL ) AS n FROM %i WHERE course_id = %d', Db::table( 'course_waitlist' ), $course_id ); return array( 'confirmed' => (int) ( $row['c'] ?? 0 ), 'pending' => (int) ( $row['p'] ?? 0 ), 'notified' => (int) ( $row['n'] ?? 0 ), ); } public function launch( int $limit = self::BATCH ): array { global $wpdb; $limit = max( 1, $limit ); $none = array( 'claimed' => 0, 'sent' => 0, 'more' => false, ); $lock = 'rlwl:' . substr( md5( ( defined( 'DB_NAME' ) ? DB_NAME : '' ) . '|' . $wpdb->prefix ), 0, 16 ); if ( '1' !== (string) Db::value( 'SELECT GET_LOCK(%s, 0)', $lock ) ) { return $none; } try { $picked = $this->claim( $limit ); } finally { Db::value( 'SELECT RELEASE_LOCK(%s)', $lock ); } list( $rows, $claimed, $more ) = $picked; if ( array() === $rows ) { return $none; } $t = Notices::course_open_row(); $sent = 0; if ( null !== $t ) { foreach ( $rows as $r ) { $sent += Notices::course_open( $t, (int) $r['course_id'], (int) ( $r['user_id'] ?? 0 ), (string) ( $r['email'] ?? '' ), (string) $r['lang'], (string) ( $r['token'] ?? '' ) ) ? 1 : 0; } } return array( 'claimed' => $claimed, 'sent' => $sent, 'more' => $more, ); } private function claim( int $limit ): array { global $wpdb; $now = Clock::now(); $rows = Db::rows( 'SELECT w.id, w.course_id, w.user_id, w.email, w.lang, w.token FROM %i w JOIN %i c ON c.id = w.course_id WHERE w.notified_at IS NULL AND w.confirmed_at IS NOT NULL AND ( c.coming_soon_flags & %d ) > 0 AND c.available_from IS NOT NULL AND c.available_from <= %s AND c.state = %d ORDER BY w.id LIMIT %d', Db::table( 'course_waitlist' ), Db::table( 'courses' ), CourseFacts::NOTIFY_WAITLIST, $now, ObjectState::ACTIVE->value, $limit + 1 ); $more = count( $rows ) > $limit; $rows = array_slice( $rows, 0, $limit ); if ( array() === $rows ) { return array( array(), 0, false ); } $ids = array_map( static fn( array $r ): int => (int) $r['id'], $rows ); $claimed = (int) Transaction::run( static function () use ( $ids, $wpdb ): int { return Db::query( 'UPDATE %i SET notified_at = %s WHERE notified_at IS NULL AND id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( Db::table( 'course_waitlist' ), Transaction::now() ), $ids ) ); } ); if ( count( $rows ) !== $claimed ) { return array( array(), $claimed, $more ); } return array( $rows, $claimed, $more ); } public function sweep(): int { $table = Db::table( 'course_waitlist' ); $n = Db::query( 'DELETE FROM %i WHERE confirmed_at IS NULL AND created_at < %s LIMIT 5000', $table, gmdate( 'Y-m-d H:i:s', Clock::timestamp() - self::CONFIRM_HOURS * HOUR_IN_SECONDS ) ); return $n + Db::query( 'DELETE FROM %i WHERE notified_at IS NOT NULL AND notified_at < %s LIMIT 5000', $table, gmdate( 'Y-m-d H:i:s', Clock::timestamp() - self::RETAIN_DAYS * DAY_IN_SECONDS ) ); } public function delete_course( int $course_id ): int { return $course_id > 0 ? Db::query( 'DELETE FROM %i WHERE course_id = %d', Db::table( 'course_waitlist' ), $course_id ) : 0; } public function of_email( string $email ): array { $email = strtolower( trim( $email ) ); return '' === $email ? array() : Db::rows( 'SELECT * FROM %i WHERE email = %s ORDER BY id LIMIT 500', Db::table( 'course_waitlist' ), $email ); } public function erase_email( string $email ): int { $email = strtolower( trim( $email ) ); return '' === $email ? 0 : Db::query( 'DELETE FROM %i WHERE email = %s', Db::table( 'course_waitlist' ), $email ); } public static function course_title( int $course_id ): string { return (string) ( Services::posts()->titles( ObjectType::COURSE, array( $course_id ) )[ $course_id ]['title'] ?? '' ); } private function require_open( int $course_id ): void { if ( ! CourseFacts::exists( $course_id ) ) { DomainError::raise( ErrorCode::NOT_FOUND ); } if ( ! CourseFacts::coming_soon( $course_id ) ) { DomainError::raise( ErrorCode::NOT_OPEN, array( 'reason' => 'not_coming_soon' ) ); } } private function send_confirmation( string $email, int $course_id, string $token, string $lang ): void { Switcher::with( $lang, static function () use ( $email, $course_id, $token, $lang ): void { $title = self::course_title( $course_id ); $site = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); /* translators: %s: site name. */ $subject = sprintf( __( 'Confirm your e-mail for %s', 'rooster-lms' ), $site ); $p = static fn( string $text, string $style = 'margin:0 0 12px;' ): string => '' . esc_html( $text ) . '
'; $hours = self::CONFIRM_HOURS; $body = $p( /* translators: %s: course title. */ sprintf( __( 'You asked us to e-mail you when the course "%s" opens. Confirm your address to join its waiting list:', 'rooster-lms' ), $title ) ) . '' /* translators: %d: hours. */ . $p( sprintf( _n( 'The link works for %d hour.', 'The link works for %d hours.', $hours, 'rooster-lms' ), $hours ) ) . $p( __( 'If you did not ask for this, ignore this message: nothing is kept and you will not get e-mails from us.', 'rooster-lms' ), 'margin:16px 0 0;font-size:12px;color:#646970;' ); Services::notifier()->deliver( $email, $subject, $body, 0, $lang ); } ); } }