(int) Db::value( 'SELECT /* outside-tx */ id FROM %i WHERE course_id = %d AND kind = %d ORDER BY id LIMIT 1', Db::table( 'invitations' ), $course_id, InvitationKind::INTEREST->value ); $id = $find(); if ( $id <= 0 && $create ) { $lock = 'rooster_lms_interest_' . $course_id; Db::value( 'SELECT GET_LOCK( %s, 5 )', $lock ); try { $id = $find(); if ( $id <= 0 ) { $now = Clock::now(); Db::query( 'INSERT INTO %i (kind, course_id, unit_id, occurrence_id, version, rsvp, yes_action, places, state, lang, created_by, created_at, updated_at) VALUES (%d, %d, 0, 0, 1, 0, 0, 0, %d, %s, 0, %s, %s)', Db::table( 'invitations' ), InvitationKind::INTEREST->value, $course_id, InvitationState::DRAFT->value, Names::default_language(), $now, $now ); $id = Db::insert_id(); } } finally { Db::value( 'SELECT RELEASE_LOCK( %s )', $lock ); } } self::$lists[ $course_id ] = max( 0, $id ); return self::$lists[ $course_id ]; } public static function add( int $course_id, int $user_id, string $email, string $lang = '', string $first = '', string $last = '' ): bool { if ( null === Services::course_repo()->get( $course_id ) ) { return false; } if ( $user_id > 0 ) { $user = get_userdata( $user_id ); $email = $user instanceof \WP_User ? (string) $user->user_email : ''; } $email = Tokens::address( $email ); if ( '' === $email ) { return false; } $list = self::list_id( $course_id, true ); if ( $list <= 0 ) { return false; } $guest = $user_id <= 0; Db::query( 'INSERT IGNORE INTO %i (invitation_id, user_id, email_hash, email, first_name, last_name, lang, source, state, created_at) VALUES (%d, %d, %s, %s, %s, %s, %s, %d, %d, %s)', Db::table( 'invitees' ), $list, max( 0, $user_id ), Tokens::email_hash( $email ), $guest ? $email : '', $guest ? mb_substr( sanitize_text_field( $first ), 0, 100 ) : '', $guest ? mb_substr( sanitize_text_field( $last ), 0, 100 ) : '', mb_substr( Language::normalize( $lang ), 0, 12 ), InviteeSource::INTEREST->value, InviteeState::QUEUED->value, Clock::now() ); unset( self::$counts[ $course_id ] ); InvitationService::mark_used(); do_action( 'rooster_lms_interest_added', $course_id, max( 0, $user_id ) ); return true; } public static function count( int $course_id ): int { if ( $course_id <= 0 ) { return 0; } if ( ! isset( self::$counts[ $course_id ] ) ) { self::$counts[ $course_id ] = (int) Db::value( "SELECT /* outside-tx */ COUNT(*) FROM %i x JOIN %i v ON v.id = x.invitation_id WHERE v.course_id = %d AND v.kind = %d AND ( x.user_id > 0 OR x.email <> '' )", Db::table( 'invitees' ), Db::table( 'invitations' ), $course_id, InvitationKind::INTEREST->value ); } return self::$counts[ $course_id ]; } public static function remove( int $course_id, string $email_hash, int $user_id ): int { if ( '' === $email_hash && $user_id <= 0 ) { return 0; } global $wpdb; $who = array(); if ( '' !== $email_hash ) { $who[] = SqlPart::of( 'x.email_hash = %s', $email_hash ); } if ( $user_id > 0 ) { $who[] = SqlPart::of( 'x.user_id = %d', $user_id ); } $sql = SqlPart::of( 'DELETE x FROM %i x JOIN %i v ON v.id = x.invitation_id WHERE v.kind = %d', Db::table( 'invitees' ), Db::table( 'invitations' ), InvitationKind::INTEREST->value )->add( $course_id > 0 ? SqlPart::of( ' AND v.course_id = %d', $course_id ) : SqlPart::of( '' ) )->add( ' AND ( ' )->add( SqlPart::join( ' OR ', $who ) )->add( ' )' ); $n = max( 0, Db::query( $sql ) ); $copies = SqlPart::of( 'DELETE x FROM %i x JOIN %i v ON v.id = x.invitation_id WHERE v.kind <> %d AND x.source = %d AND x.sent_at IS NULL AND x.state IN (%d, %d)', Db::table( 'invitees' ), Db::table( 'invitations' ), InvitationKind::INTEREST->value, InviteeSource::INTEREST->value, InviteeState::QUEUED->value, InviteeState::FAILED->value )->add( $course_id > 0 ? SqlPart::of( ' AND v.course_id = %d', $course_id ) : SqlPart::of( '' ) )->add( ' AND ( ' )->add( SqlPart::join( ' OR ', $who ) )->add( ' )' ); if ( Db::query( $copies ) > 0 ) { InvitationRepository::forget(); } self::$counts = array(); return $n; } public static function courses_of( int $user_id ): array { if ( $user_id <= 0 ) { return array(); } return array_map( 'intval', Db::col( 'SELECT /* outside-tx */ v.course_id FROM %i x JOIN %i v ON v.id = x.invitation_id WHERE x.user_id = %d AND v.kind = %d ORDER BY x.id DESC LIMIT 50', Db::table( 'invitees' ), Db::table( 'invitations' ), $user_id, InvitationKind::INTEREST->value ) ); } public static function stop_url( int $course_id, string $email, int $user_id = 0 ): string { if ( $user_id > 0 && '' === $email ) { $user = get_userdata( $user_id ); $email = $user instanceof \WP_User ? (string) $user->user_email : ''; } $token = Crypto::seal( (string) wp_json_encode( array( 'k' => 'stop', 'c' => $course_id, 'h' => '' !== $email ? Tokens::email_hash( $email ) : '', 'u' => max( 0, $user_id ), 'l' => Switcher::current(), ) ) ); return add_query_arg( self::STOP_ARG, rawurlencode( $token ), home_url( '/' ) ); } public static function open( string $token ): ?array { $json = Crypto::open( $token ); $data = '' === $json ? null : json_decode( $json, true ); if ( ! is_array( $data ) || 'stop' !== ( $data['k'] ?? '' ) || (int) ( $data['c'] ?? 0 ) <= 0 ) { return null; } $hash = (string) ( $data['h'] ?? '' ); $user = max( 0, (int) ( $data['u'] ?? 0 ) ); if ( ( '' !== $hash && 1 !== preg_match( '/^[0-9a-f]{64}$/', $hash ) ) || ( '' === $hash && 0 === $user ) ) { return null; } return array( 'c' => (int) $data['c'], 'h' => $hash, 'u' => $user, 'l' => sanitize_text_field( (string) ( $data['l'] ?? '' ) ), ); } public static function secret( string $token ): string { return substr( hash_hmac( 'sha256', 'rooster-lms-interest-stop|' . $token, wp_salt( 'nonce' ) ), 0, 32 ); } public static function nonce_action( string $token ): string { return 'rooster_lms_interest_stop_' . $token; } public static function stop_route(): void { $raw = \RoosterLMS\UI\TableRequest::get( self::STOP_ARG ); if ( ! is_string( $raw ) || '' === $raw ) { return; } $token = substr( preg_replace( '/[^A-Za-z0-9_:\-]/', '', $raw ) ?? '', 0, 600 ); $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( self::SECRET ); $page = self::stop_page( $token, $post, $secret ); 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 static function stop_page( string $token, bool $post, string $secret ): string { $data = self::open( $token ); if ( null === $data ) { return '
' . esc_html__( 'The link may be incomplete.', 'rooster-lms' ) . '
'; } return (string) Switcher::with( '' !== $data['l'] ? $data['l'] : Names::default_language(), static function () use ( $data, $token, $post, $secret ): string { $title = (string) ( Services::posts()->titles( \RoosterLMS\Content\ObjectType::COURSE, array( $data['c'] ), Switcher::current() )[ $data['c'] ]['title'] ?? '' ); /* translators: %s: course title. */ $head = '' . esc_html__( 'You asked us to e-mail you when this course has new lessons. Stop these notices?', 'rooster-lms' ) . '
' . ''; } self::remove( $data['c'], $data['h'], $data['u'] ); return $head . '' . esc_html__( 'Done: you will not get notices about this course. Invitations you received already keep their own unsubscribe link.', 'rooster-lms' ) . '
'; } ); } public static function erase( string $email_hash, int $user_id ): int { return self::remove( 0, $email_hash, $user_id ); } public static function forget(): void { self::$lists = array(); self::$counts = array(); } }