get( $course_id ); return null !== $course && $user_id > 0 && Users::can( $user_id, 'edit_post', $course->home_post_id ); } private function authorize( int $course_id, Actor $actor ): Course { $course = Services::course_repo()->get( $course_id ); if ( null === $course ) { DomainError::raise( ErrorCode::NOT_FOUND ); } Authorize::edit_post( $actor, $course->home_post_id ); return $course; } public function update( int $course_id, array $patch, Actor $actor, string $lang = '' ): Course { $course = $this->authorize( $course_id, $actor ); $set = array(); if ( array_key_exists( 'level_id', $patch ) ) { $id = (int) $patch['level_id']; if ( $id > 0 && $id !== $course->level_id && ! ( Services::course_levels()->get( $id )?->is_active() ?? false ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'level' ) ); } $set['level_id'] = $id > 0 ? $id : null; } if ( array_key_exists( 'badge_id', $patch ) ) { $id = (int) $patch['badge_id']; if ( $id > 0 && $id !== $course->badge_id && ! ( Services::course_badges()->get( $id )?->is_active() ?? false ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'badge' ) ); } $set['badge_id'] = $id > 0 ? $id : null; } foreach ( array( 'badge_from', 'badge_until', 'available_from' ) as $key ) { if ( array_key_exists( $key, $patch ) ) { $set[ $key ] = self::utc( $patch[ $key ] ); } } $from = array_key_exists( 'badge_from', $set ) ? $set['badge_from'] : $course->badge_from; $until = array_key_exists( 'badge_until', $set ) ? $set['badge_until'] : $course->badge_until; if ( null !== $from && null !== $until && $until <= $from ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'badge_window' ) ); } if ( array_key_exists( 'is_featured', $patch ) ) { $set['is_featured'] = ! empty( $patch['is_featured'] ) ? 1 : 0; } foreach ( array( 'qa_mode' => \RoosterLMS\CourseBuilder\Settings\CourseModes::QA_MAX, 'reviews_mode' => 2, ) as $key => $max ) { if ( array_key_exists( $key, $patch ) ) { $mode = (int) $patch[ $key ]; if ( $mode < 0 || $mode > $max ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => $key ) ); } $set[ $key ] = $mode; } } if ( array_key_exists( 'coming_soon_flags', $patch ) ) { $set['coming_soon_flags'] = max( 0, min( 63, (int) $patch['coming_soon_flags'] ) ); } if ( array_key_exists( 'page_template', $patch ) ) { $set['page_template'] = PageTemplates::clean( (string) $patch['page_template'] ); if ( '' !== trim( (string) $patch['page_template'] ) && '' === $set['page_template'] ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'page_template' ) ); } } $labels = array(); foreach ( array( 'duration_label', 'video_duration_label' ) as $key ) { if ( ! array_key_exists( $key, $patch ) ) { continue; } $text = mb_substr( trim( sanitize_text_field( (string) $patch[ $key ] ) ), 0, 100 ); $old = 'duration_label' === $key ? $course->duration_label : $course->video_duration_label; if ( ! Strings::writes_stored( $lang ) && '' !== $old ) { Services::strings()->save_translation( TextObject::COURSE, $course_id, $old, $lang, $text, Actor::system(), $key ); continue; } $set[ $key ] = $text; $labels[ $key ] = $old; } if ( array() !== $set ) { Transaction::run( static function () use ( $course_id, $set ): void { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'courses' ), $course_id ); foreach ( $set as $column => $value ) { if ( null === $value ) { Db::query( 'UPDATE %i SET %i = NULL WHERE id = %d', Db::table( 'courses' ), $column, $course_id ); } elseif ( is_int( $value ) ) { Db::query( 'UPDATE %i SET %i = %d WHERE id = %d', Db::table( 'courses' ), $column, $value, $course_id ); } else { Db::query( 'UPDATE %i SET %i = %s WHERE id = %d', Db::table( 'courses' ), $column, $value, $course_id ); } } Db::query( 'UPDATE %i SET updated_at = %s WHERE id = %d', Db::table( 'courses' ), Transaction::now(), $course_id ); } ); Services::course_repo()->forget( array( $course_id ) ); CourseCatalogue::forget_counts(); PageTemplates::forget(); PageTemplates::mark_used( (string) ( $set['page_template'] ?? '' ) ); if ( array_key_exists( 'available_from', $set ) || array_key_exists( 'coming_soon_flags', $set ) ) { Services::gate()->forget(); } if ( array_key_exists( 'qa_mode', $set ) || array_key_exists( 'reviews_mode', $set ) ) { \RoosterLMS\Community\CourseFacts::forget(); } foreach ( $labels as $key => $old ) { if ( '' === $set[ $key ] ) { Services::strings()->unregister_fields( TextObject::COURSE, $course_id, array( $key ) ); } else { Services::strings()->register( TextObject::COURSE, $course_id, (string) $set[ $key ], $key, $old ); } } do_action( 'rooster_lms_course_details_changed', $course_id, array_keys( $set ) ); } return Services::course_repo()->get( $course_id ) ?? $course; } public function texts( int $course_id ): array { if ( ! isset( $this->texts[ $course_id ] ) ) { $this->texts[ $course_id ] = array(); foreach ( Db::rows( 'SELECT kind, html FROM %i WHERE course_id = %d ORDER BY kind', Db::table( 'course_texts' ), $course_id ) as $row ) { $this->texts[ $course_id ][ (int) $row['kind'] ] = (string) $row['html']; } } return $this->texts[ $course_id ]; } public function texts_in( int $course_id, string $lang = '' ): array { $defaults = array(); foreach ( $this->texts( $course_id ) as $kind => $html ) { $case = CourseTextKind::tryFrom( $kind ); if ( null !== $case ) { $defaults[ $case->field() ] = $html; } } return array() === $defaults ? array() : Services::strings()->translate_fields( TextObject::COURSE, $course_id, $defaults, '' !== $lang ? $lang : Names::display_language() ); } public function set_text( int $course_id, CourseTextKind $kind, string $html, Actor $actor, string $lang = '' ): void { $this->authorize( $course_id, $actor ); $html = CourseTextKind::SUBTITLE === $kind ? self::subtitle( $html ) : mb_substr( trim( wp_kses_post( $html ) ), 0, self::TEXT_MAX ); $old = $this->texts( $course_id )[ $kind->value ] ?? ''; if ( ! Strings::writes_stored( $lang ) && '' !== $old ) { Services::strings()->save_translation( TextObject::COURSE, $course_id, $old, $lang, $html, Actor::system(), $kind->field() ); return; } if ( $html === $old ) { return; } $this->write_text( $course_id, $kind, $html ); if ( '' === $html ) { Services::strings()->unregister_fields( TextObject::COURSE, $course_id, array( $kind->field() ) ); } else { Services::strings()->register( TextObject::COURSE, $course_id, $html, $kind->field(), $old ); Services::strings()->created_in( TextObject::COURSE, $course_id, $html, '' === $old ? $lang : '', Actor::system(), $kind->field() ); } } public function write_text( int $course_id, CourseTextKind $kind, string $html ): void { Transaction::run( static function () use ( $course_id, $kind, $html ): void { if ( '' === $html ) { Db::query( 'DELETE FROM %i WHERE course_id = %d AND kind = %d', Db::table( 'course_texts' ), $course_id, $kind->value ); return; } Db::query( 'INSERT INTO %i (course_id, kind, html, updated_at) VALUES (%d, %d, %s, %s) ON DUPLICATE KEY UPDATE html = VALUES(html), updated_at = VALUES(updated_at)', Db::table( 'course_texts' ), $course_id, $kind->value, $html, Transaction::now() ); } ); unset( $this->texts[ $course_id ] ); } public static function subtitle( string $text ): string { return mb_substr( trim( sanitize_text_field( $text ) ), 0, 255 ); } public function points( int $course_id ): array { if ( ! isset( $this->points[ $course_id ] ) ) { $this->points[ $course_id ] = array(); foreach ( Db::rows( 'SELECT id, kind, text FROM %i WHERE course_id = %d ORDER BY kind, position, id', Db::table( 'course_points' ), $course_id ) as $row ) { $this->points[ $course_id ][ (int) $row['kind'] ][] = array( 'id' => (int) $row['id'], 'text' => (string) $row['text'], ); } } return $this->points[ $course_id ]; } public function points_in( int $course_id, string $lang = '' ): array { $all = $this->points( $course_id ); $defaults = array(); foreach ( $all as $list ) { foreach ( $list as $point ) { $defaults[ $point['id'] ] = $point['text']; } } $texts = array() === $defaults ? array() : Services::strings()->translate_many( TextObject::COURSE_POINT, $defaults, '' !== $lang ? $lang : Names::display_language(), 'text' ); $out = array(); foreach ( CoursePointKind::cases() as $kind ) { $out[ $kind->slug() ] = array_map( static fn( array $p ): string => $texts[ $p['id'] ] ?? $p['text'], $all[ $kind->value ] ?? array() ); } return $out; } public function set_points( int $course_id, CoursePointKind $kind, array $texts, Actor $actor, string $lang = '' ): void { $this->authorize( $course_id, $actor ); $texts = self::clean_points( $texts ); $old = $this->points( $course_id )[ $kind->value ] ?? array(); if ( ! Strings::writes_stored( $lang ) && array() !== $old ) { if ( count( $texts ) !== count( $old ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'points_count' ) ); } foreach ( $old as $i => $point ) { Services::strings()->save_translation( TextObject::COURSE_POINT, $point['id'], $point['text'], $lang, $texts[ $i ], Actor::system(), 'text' ); } return; } $ids = $this->write_points( $course_id, $kind, $texts ); foreach ( $ids as $i => $id ) { Services::strings()->register( TextObject::COURSE_POINT, $id, $texts[ $i ], 'text', $old[ $i ]['text'] ?? '' ); if ( ! isset( $old[ $i ] ) ) { Services::strings()->created_in( TextObject::COURSE_POINT, $id, $texts[ $i ], $lang, Actor::system(), 'text' ); } } } public function write_points( int $course_id, CoursePointKind $kind, array $texts ): array { $ids = (array) Transaction::run( static function () use ( $course_id, $kind, $texts ): array { global $wpdb; $table = Db::table( 'course_points' ); Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'courses' ), $course_id ); $have = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE course_id = %d AND kind = %d ORDER BY position, id FOR UPDATE', $table, $course_id, $kind->value ) ); $ids = array(); foreach ( array_values( $texts ) as $i => $text ) { if ( isset( $have[ $i ] ) ) { Db::query( 'UPDATE %i SET text = %s, position = %d WHERE id = %d', $table, $text, $i, $have[ $i ] ); $ids[] = $have[ $i ]; } else { Db::query( 'INSERT INTO %i (course_id, kind, position, text) VALUES (%d, %d, %d, %s)', $table, $course_id, $kind->value, $i, $text ); $ids[] = Db::insert_id(); } } $gone = array_slice( $have, count( $ids ) ); if ( array() !== $gone ) { Db::query( SqlPart::of( 'DELETE FROM %i WHERE 1 = 1', $table )->add( SqlPart::and_in( 'id', $gone ) ) ); Services::strings()->delete_objects( TextObject::COURSE_POINT, $gone ); } return $ids; } ); unset( $this->points[ $course_id ] ); return array_map( 'intval', $ids ); } public static function clean_points( array $texts ): array { $out = array(); foreach ( $texts as $text ) { $text = is_scalar( $text ) ? mb_substr( trim( sanitize_text_field( (string) $text ) ), 0, self::POINT_MAX ) : ''; if ( '' !== $text ) { $out[] = $text; } } return array_slice( $out, 0, self::MAX_POINTS ); } public function delete_course( int $course_id ): void { $points = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE course_id = %d', Db::table( 'course_points' ), $course_id ) ); $files = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE course_id = %d', Db::table( 'course_files' ), $course_id ) ); Db::query( 'DELETE FROM %i WHERE course_id = %d', Db::table( 'course_points' ), $course_id ); Db::query( 'DELETE FROM %i WHERE course_id = %d', Db::table( 'course_texts' ), $course_id ); Db::query( 'DELETE FROM %i WHERE course_id = %d', Db::table( 'course_files' ), $course_id ); Services::strings()->delete_objects( TextObject::COURSE_POINT, $points ); Services::strings()->delete_objects( TextObject::COURSE_FILE, $files ); Services::strings()->delete_objects( TextObject::COURSE, array( $course_id ) ); $this->forget(); Services::course_files()->forget(); } public function shape( Course $course, string $lang ): array { $labels = array(); foreach ( array( 'duration_label', 'video_duration_label' ) as $key ) { if ( '' !== $course->$key ) { $labels[ $key ] = $course->$key; } } $labels = array() === $labels ? array() : Services::strings()->translate_fields( TextObject::COURSE, $course->id, $labels, $lang ); $level = $course->level_id > 0 ? Services::course_levels()->get( $course->level_id ) : null; $badge = $course->badge_id > 0 ? Services::course_badges()->get( $course->badge_id ) : null; $now = Transaction::now(); return array( 'id' => $course->id, 'level' => null === $level ? null : array( 'id' => $level->id, 'slug' => $level->slug, 'label' => Services::course_levels()->label( $level, $lang ), ), 'badge' => null === $badge ? null : array( 'id' => $badge->id, 'slug' => $badge->slug, 'label' => Services::course_badges()->label( $badge, $lang ), 'color' => $badge->color, 'icon' => $badge->icon, 'active' => $course->badge_active( $now ), ), 'badge_from' => $course->badge_from, 'badge_until' => $course->badge_until, 'is_featured' => $course->is_featured, 'duration_label' => $labels['duration_label'] ?? '', 'video_duration_label' => $labels['video_duration_label'] ?? '', 'coming_soon' => array( 'flags' => ComingSoonFlag::slugs( $course->coming_soon_flags ), 'launch' => $course->available_from, 'active' => $course->is_coming_soon( $now ), 'enrol' => ComingSoon::enrolment_open( $course, $now ), 'message' => $this->texts_in( $course->id, $lang )['coming_soon'] ?? '', ), 'page_template' => $course->page_template, 'texts' => $this->texts_in( $course->id, $lang ), 'points' => $this->points_in( $course->id, $lang ), ); } public function forget(): void { $this->texts = array(); $this->points = array(); } public static function utc( $value ): ?string { if ( null === $value || '' === $value || false === $value ) { return null; } if ( is_int( $value ) || ( is_string( $value ) && ctype_digit( $value ) ) ) { return (int) $value > 0 ? gmdate( 'Y-m-d H:i:s', (int) $value ) : null; } if ( ! is_string( $value ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'date' ) ); } $value = trim( $value ); if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value ) ) { $date = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $value, new \DateTimeZone( 'UTC' ) ); } else { try { $date = new \DateTimeImmutable( $value, new \DateTimeZone( 'UTC' ) ); } catch ( \Exception $e ) { unset( $e ); $date = false; } } if ( false === $date ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'date' ) ); } return $date->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); } }