array(), 'br' => array(), 'strong' => array(), 'b' => array(), 'em' => array(), 'i' => array(), 'code' => array(), 'pre' => array(), 'blockquote' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'a' => array( 'href' => true, 'rel' => true, ), ); public static function clean( string $body ): string { $body = trim( wp_kses( $body, self::KSES_TAGS ) ); return mb_substr( $body, 0, self::MAX_BODY ); } public function post( int $user_id, int $course_id, int $unit_id, int $parent_id, string $body, string $lang = '', bool $asked_private = false ): array { if ( $user_id <= 0 ) { DomainError::raise( ErrorCode::FORBIDDEN ); } $body = self::clean( $body ); if ( '' === $body ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'body' ) ); } $lang = Language::normalize( '' !== $lang ? $lang : Switcher::current() ); $parent = null; if ( $parent_id > 0 ) { $parent = $this->get( $parent_id ); if ( null === $parent || 0 !== (int) $parent['parent_id'] || ( $course_id > 0 && (int) $parent['course_id'] !== $course_id ) ) { DomainError::raise( ErrorCode::NOT_FOUND, array( 'reason' => 'question' ) ); } $course_id = (int) $parent['course_id']; $unit_id = (int) $parent['unit_id']; } $staff = CourseFacts::is_staff( $user_id, $course_id ); if ( ! $staff && ! CourseFacts::is_learner( $user_id, $course_id ) ) { DomainError::raise( ErrorCode::FORBIDDEN, array( 'reason' => 'not_member' ) ); } if ( ! CourseFacts::placed( $course_id, $unit_id ) ) { DomainError::raise( ErrorCode::NOT_FOUND, array( 'reason' => 'placement' ) ); } $mode = CourseFacts::qa_mode( $course_id ); if ( 1 === $mode ) { DomainError::raise( ErrorCode::FORBIDDEN, array( 'reason' => 'qa_off' ) ); } if ( null !== $parent && ( ( DiscussionState::VISIBLE->value !== (int) $parent['state'] && ! $staff ) || ! $this->can_read( $user_id, $parent ) ) ) { DomainError::raise( ErrorCode::NOT_FOUND, array( 'reason' => 'question' ) ); } if ( null === $parent && ! $staff && self::LED === $mode ) { DomainError::raise( ErrorCode::FORBIDDEN, array( 'reason' => 'instructor_led' ) ); } if ( null === $parent && $asked_private && ! $staff && ! CourseFacts::qa_private( $course_id ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'private' ) ); } $private = null !== $parent ? 1 === (int) ( $parent['is_private'] ?? 0 ) : $asked_private && ! $staff; if ( ! $staff ) { $this->check_rate( $user_id ); } $hold = $private ? 2 : self::hold_mode( $mode ); $id = (int) Transaction::run( function () use ( $user_id, $course_id, $unit_id, $parent_id, $body, $lang, $staff, $hold, $private ): int { $now = Transaction::now(); if ( $parent_id > 0 ) { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'discussions' ), $parent_id ); } $state = DiscussionState::VISIBLE; if ( ! $staff && ( 4 === $hold || ( 3 === $hold && ! $this->has_visible( $user_id, $course_id ) ) ) ) { $state = DiscussionState::PENDING; } Db::query( 'INSERT INTO %i (course_id, unit_id, parent_id, user_id, body, state, is_answer, pinned, reply_count, lang, last_activity_at, created_at, updated_at, is_private) VALUES (%d, %d, %d, %d, %s, %d, 0, 0, 0, %s, %s, %s, %s, %d)', Db::table( 'discussions' ), $course_id, $unit_id, $parent_id, $user_id, $body, $state->value, $lang, $now, $now, $now, $private ? 1 : 0 ); $id = Db::insert_id(); if ( $parent_id > 0 ) { self::recount( $parent_id ); } return $id; } ); if ( ! $staff ) { Notices::qa_posted( $course_id, $unit_id, $user_id ); } return (array) $this->get( $id ); } public function edit( int $actor_id, int $id, string $body ): array { $row = $this->require_row( $id ); $course = (int) $row['course_id']; $staff = CourseFacts::is_staff( $actor_id, $course ); $own = (int) $row['user_id'] === $actor_id && $actor_id > 0 && in_array( (int) $row['state'], array( DiscussionState::VISIBLE->value, DiscussionState::PENDING->value ), true ); if ( ! $own && ! $staff ) { DomainError::raise( ErrorCode::FORBIDDEN ); } $mode = $staff ? 0 : CourseFacts::qa_mode( $course ); if ( 1 === $mode ) { DomainError::raise( ErrorCode::FORBIDDEN, array( 'reason' => 'qa_off' ) ); } $body = self::clean( $body ); if ( '' === $body ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'body' ) ); } $hold = 1 !== (int) ( $row['is_private'] ?? 0 ) && in_array( self::hold_mode( $mode ), array( 3, 4 ), true ); global $wpdb; $held = (bool) Transaction::run( static function () use ( $id, $body, $hold, $wpdb ): bool { $table = Db::table( 'discussions' ); $now = Db::row( 'SELECT parent_id, state FROM %i WHERE id = %d FOR UPDATE', $table, $id ); if ( null === $now ) { return false; } $held = $hold && DiscussionState::VISIBLE->value === (int) $now['state']; if ( $held ) { Db::query( 'UPDATE %i SET body = %s, state = %d, updated_at = %s WHERE id = %d', $table, $body, DiscussionState::PENDING->value, Transaction::now(), $id ); if ( (int) $now['parent_id'] > 0 ) { self::recount( (int) $now['parent_id'] ); } } else { Db::query( 'UPDATE %i SET body = %s, updated_at = %s WHERE id = %d', $table, $body, Transaction::now(), $id ); } return $held; } ); if ( $held ) { Notices::qa_posted( $course, (int) $row['unit_id'], $actor_id ); } return (array) $this->get( $id ); } public function moderate( int $actor_id, int $id, ?DiscussionState $state, ?bool $pinned = null, ?bool $is_answer = null ): array { $row = $this->require_row( $id ); if ( ! CourseFacts::is_staff( $actor_id, (int) $row['course_id'] ) ) { DomainError::raise( ErrorCode::FORBIDDEN ); } $question = 0 === (int) $row['parent_id']; if ( ( null !== $pinned && ! $question ) || ( null !== $is_answer && $question ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => $question ? 'is_answer' : 'pinned' ) ); } $this->set( $id, $state, $pinned, $is_answer ); return (array) $this->get( $id ); } public function set( int $id, ?DiscussionState $state, ?bool $pinned = null, ?bool $is_answer = null ): void { Transaction::run( static function () use ( $id, $state, $pinned, $is_answer ): void { global $wpdb; $row = Db::row( 'SELECT id, parent_id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'discussions' ), $id ); if ( null === $row ) { return; } $sets = array(); if ( null !== $state ) { $sets[] = SqlPart::of( 'state = %d', $state->value ); } if ( null !== $pinned ) { $sets[] = SqlPart::of( 'pinned = %d', $pinned ? 1 : 0 ); } if ( null !== $is_answer ) { $sets[] = SqlPart::of( 'is_answer = %d', $is_answer ? 1 : 0 ); } if ( array() === $sets ) { return; } $sets[] = SqlPart::of( 'updated_at = %s', Transaction::now() ); Db::query( SqlPart::of( 'UPDATE %i SET ', Db::table( 'discussions' ) )->add( SqlPart::join( ', ', $sets ) )->add( SqlPart::of( ' WHERE id = %d', $id ) ) ); if ( (int) $row['parent_id'] > 0 && null !== $state ) { self::recount( (int) $row['parent_id'] ); } } ); } public function get( int $id ): ?array { return $id > 0 ? Db::row( 'SELECT * FROM %i WHERE id = %d', Db::table( 'discussions' ), $id ) : null; } public function can_read( int $viewer_id, array $row ): bool { $course = (int) $row['course_id']; if ( CourseFacts::is_staff( $viewer_id, $course ) ) { return true; } if ( 1 === CourseFacts::qa_mode( $course ) || ! CourseFacts::is_learner( $viewer_id, $course ) ) { return false; } if ( 1 === (int) ( $row['is_private'] ?? 0 ) ) { $question = (int) $row['parent_id'] > 0 ? $this->get( (int) $row['parent_id'] ) : $row; if ( null === $question || (int) $question['user_id'] !== $viewer_id ) { return false; } } $state = (int) $row['state']; return DiscussionState::VISIBLE->value === $state || ( DiscussionState::PENDING->value === $state && (int) $row['user_id'] === $viewer_id ); } public function questions( int $viewer_id, int $course_id, int $unit_id, int $page = 1, int $per_page = 20, ?DiscussionState $state = null ): array { global $wpdb; $per_page = max( 1, min( 100, $per_page ) ); $where = SqlPart::of( 'course_id = %d AND parent_id = 0', $course_id ); if ( $unit_id > 0 ) { $where = $where->add( SqlPart::of( ' AND unit_id = %d', $unit_id ) ); } $where = $where->add( $this->state_filter( $viewer_id, $course_id, $state ) ); if ( ! CourseFacts::is_staff( $viewer_id, $course_id ) ) { $where = $where->add( SqlPart::of( ' AND ( is_private = 0 OR user_id = %d )', $viewer_id ) ); } $table = Db::table( 'discussions' ); $total = (int) Db::value( SqlPart::of( 'SELECT COUNT(*) FROM %i WHERE ', $table )->add( $where ) ); $rows = Db::rows( SqlPart::of( 'SELECT * FROM %i WHERE ', $table )->add( $where )->add( SqlPart::of( ' ORDER BY pinned DESC, last_activity_at DESC, id DESC LIMIT %d OFFSET %d', $per_page, ( max( 1, $page ) - 1 ) * $per_page ) ) ); return array( 'items' => $this->shape( $rows, $viewer_id ), 'total' => $total, ); } public function thread( int $viewer_id, int $id ): ?array { $row = $this->get( $id ); if ( null !== $row && (int) $row['parent_id'] > 0 ) { $row = $this->get( (int) $row['parent_id'] ); } if ( null === $row || ! $this->can_read( $viewer_id, $row ) ) { return null; } global $wpdb; $where = SqlPart::of( 'parent_id = %d', (int) $row['id'] )->add( $this->state_filter( $viewer_id, (int) $row['course_id'], null ) ); $replies = Db::rows( SqlPart::of( 'SELECT * FROM %i WHERE ', Db::table( 'discussions' ) )->add( $where )->add( ' ORDER BY created_at, id LIMIT 500' ) ); $shaped = $this->shape( array_merge( array( $row ), $replies ), $viewer_id ); return array( 'question' => $shaped[0], 'replies' => array_slice( $shaped, 1 ), ); } public function shape( array $rows, int $viewer_id ): array { $users = array_values( array_unique( array_filter( array_map( static fn( array $r ): int => (int) $r['user_id'], $rows ) ) ) ); if ( array() !== $users ) { cache_users( $users ); } $out = array(); foreach ( $rows as $r ) { $user = (int) $r['user_id']; $data = $user > 0 ? get_userdata( $user ) : false; $state = DiscussionState::tryFrom( (int) $r['state'] ) ?? DiscussionState::HIDDEN; $out[] = array( 'id' => (int) $r['id'], 'course_id' => (int) $r['course_id'], 'unit_id' => (int) $r['unit_id'], 'parent_id' => (int) $r['parent_id'], 'user_id' => $user, 'author' => $data instanceof \WP_User ? (string) $data->display_name : '', 'body' => (string) $r['body'], 'state' => $state->slug(), 'is_answer' => 1 === (int) $r['is_answer'], 'pinned' => 1 === (int) $r['pinned'], 'private' => 1 === (int) ( $r['is_private'] ?? 0 ), 'reply_count' => (int) $r['reply_count'], 'lang' => (string) $r['lang'], 'last_activity_at' => (string) $r['last_activity_at'], 'created_at' => (string) $r['created_at'], 'updated_at' => (string) $r['updated_at'], 'own' => $user > 0 && $user === $viewer_id, ); } return $out; } public function delete_course( int $course_id ): int { return $this->delete_where( 'course_id', $course_id ); } public function delete_unit( int $unit_id ): int { return $this->delete_where( 'unit_id', $unit_id ); } public function delete_user( int $user_id ): int { if ( $user_id <= 0 ) { return 0; } return (int) Transaction::run( static function () use ( $user_id ): int { $table = Db::table( 'discussions' ); $parents = array_map( 'intval', Db::col( 'SELECT DISTINCT parent_id FROM %i WHERE user_id = %d AND parent_id > 0', $table, $user_id ) ); $own = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE user_id = %d AND parent_id = 0', $table, $user_id ) ); $n = 0; foreach ( array_chunk( $own, 500 ) as $chunk ) { $n += Db::query( 'DELETE FROM %i WHERE parent_id IN (' . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', array_merge( array( $table ), $chunk ) ); } $n += Db::query( 'DELETE FROM %i WHERE user_id = %d', $table, $user_id ); foreach ( array_diff( $parents, $own ) as $parent ) { self::recount( $parent ); } return $n; } ); } public static function recount( int $question_id ): void { $table = Db::table( 'discussions' ); Db::query( 'UPDATE %i q SET q.reply_count = ( SELECT x.n FROM ( SELECT LEAST( COUNT(*), 65535 ) AS n FROM %i r WHERE r.parent_id = %d AND r.state = %d ) x ), q.last_activity_at = GREATEST( q.created_at, COALESCE( ( SELECT y.m FROM ( SELECT MAX( r2.created_at ) AS m FROM %i r2 WHERE r2.parent_id = %d AND r2.state = %d ) y ), q.created_at ) ) WHERE q.id = %d', $table, $table, $question_id, DiscussionState::VISIBLE->value, $table, $question_id, DiscussionState::VISIBLE->value, $question_id ); } private function check_rate( int $user_id ): void { $limit = (int) CommunitySettings::get( 'qa_rate_limit' ); if ( $limit <= 0 ) { return; } $since = gmdate( 'Y-m-d H:i:s', \RoosterLMS\Core\Clock::timestamp() - HOUR_IN_SECONDS ); $n = (int) Db::value( 'SELECT COUNT(*) FROM %i WHERE user_id = %d AND created_at > %s', Db::table( 'discussions' ), $user_id, $since ); if ( $n >= $limit ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'rate_limit' ) ); } } public static function hold_mode( int $mode ): int { if ( self::LED !== $mode ) { return $mode; } $site = (int) CommunitySettings::get( 'qa_mode' ); return 3 === $site || 4 === $site ? $site : 2; } private function has_visible( int $user_id, int $course_id ): bool { return null !== Db::value( 'SELECT 1 FROM %i WHERE user_id = %d AND course_id = %d AND state = %d LIMIT 1', Db::table( 'discussions' ), $user_id, $course_id, DiscussionState::VISIBLE->value ); } private function state_filter( int $viewer_id, int $course_id, ?DiscussionState $state ): SqlPart { global $wpdb; if ( CourseFacts::is_staff( $viewer_id, $course_id ) ) { return null !== $state ? SqlPart::of( ' AND state = %d', $state->value ) : SqlPart::of( ' AND state <> %d', DiscussionState::TRASHED->value ); } if ( 1 === CourseFacts::qa_mode( $course_id ) ) { return SqlPart::of( ' AND 1 = 0' ); } return SqlPart::of( ' AND ( state = %d OR ( state = %d AND user_id = %d ) )', DiscussionState::VISIBLE->value, DiscussionState::PENDING->value, $viewer_id ); } private function require_row( int $id ): array { $row = $this->get( $id ); if ( null === $row ) { DomainError::raise( ErrorCode::NOT_FOUND ); } return $row; } private function delete_where( string $column, int $id ): int { if ( $id <= 0 ) { return 0; } $n = 0; do { $gone = Db::query( 'DELETE FROM %i WHERE %i = %d LIMIT 1000', Db::table( 'discussions' ), $column, $id ); $n += $gone; } while ( $gone >= 1000 ); return $n; } }